mirror of
https://github.com/lightninglabs/loop
synced 2024-11-11 13:11:12 +00:00
multi: add opt-in automated swap dispatch to liquidity manager
This commit is contained in:
parent
fd17580213
commit
8166d936e1
1
go.mod
1
go.mod
@ -16,6 +16,7 @@ require (
|
|||||||
github.com/lightningnetwork/lnd/cert v1.0.3
|
github.com/lightningnetwork/lnd/cert v1.0.3
|
||||||
github.com/lightningnetwork/lnd/clock v1.0.1
|
github.com/lightningnetwork/lnd/clock v1.0.1
|
||||||
github.com/lightningnetwork/lnd/queue v1.0.4
|
github.com/lightningnetwork/lnd/queue v1.0.4
|
||||||
|
github.com/lightningnetwork/lnd/ticker v1.0.0
|
||||||
github.com/stretchr/testify v1.5.1
|
github.com/stretchr/testify v1.5.1
|
||||||
github.com/urfave/cli v1.20.0
|
github.com/urfave/cli v1.20.0
|
||||||
golang.org/x/net v0.0.0-20191002035440-2ec189313ef0
|
golang.org/x/net v0.0.0-20191002035440-2ec189313ef0
|
||||||
|
290
liquidity/autoloop_test.go
Normal file
290
liquidity/autoloop_test.go
Normal file
@ -0,0 +1,290 @@
|
|||||||
|
package liquidity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/lightninglabs/lndclient"
|
||||||
|
"github.com/lightninglabs/loop"
|
||||||
|
"github.com/lightninglabs/loop/labels"
|
||||||
|
"github.com/lightninglabs/loop/loopdb"
|
||||||
|
"github.com/lightninglabs/loop/test"
|
||||||
|
"github.com/lightningnetwork/lnd/lntypes"
|
||||||
|
"github.com/lightningnetwork/lnd/lnwire"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestAutoLoopDisabled tests the case where we need to perform a swap, but
|
||||||
|
// autoloop is not enabled.
|
||||||
|
func TestAutoLoopDisabled(t *testing.T) {
|
||||||
|
defer test.Guard(t)()
|
||||||
|
|
||||||
|
// Set parameters for a channel that will require a swap.
|
||||||
|
channels := []lndclient.ChannelInfo{
|
||||||
|
channel1,
|
||||||
|
}
|
||||||
|
|
||||||
|
params := defaultParameters
|
||||||
|
params.ChannelRules = map[lnwire.ShortChannelID]*ThresholdRule{
|
||||||
|
chanID1: chanRule,
|
||||||
|
}
|
||||||
|
|
||||||
|
c := newAutoloopTestCtx(t, params, channels)
|
||||||
|
c.start()
|
||||||
|
|
||||||
|
// We expect a single quote to be required for our swap on channel 1.
|
||||||
|
// We set its quote to have acceptable fees for our current limit.
|
||||||
|
quotes := []quoteRequestResp{
|
||||||
|
{
|
||||||
|
request: &loop.LoopOutQuoteRequest{
|
||||||
|
Amount: chan1Rec.Amount,
|
||||||
|
SweepConfTarget: chan1Rec.SweepConfTarget,
|
||||||
|
},
|
||||||
|
quote: testQuote,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// Trigger an autoloop attempt for our test context with no existing
|
||||||
|
// loop in/out swaps. We expect a swap for our channel to be suggested,
|
||||||
|
// but do not expect any swaps to be executed, since autoloop is
|
||||||
|
// disabled by default.
|
||||||
|
c.autoloop(1, chan1Rec.Amount+1, nil, quotes, nil)
|
||||||
|
|
||||||
|
// Trigger another autoloop, this time setting our server restrictions
|
||||||
|
// to have a minimum swap amount greater than the amount that we need
|
||||||
|
// to swap. In this case we don't even expect to get a quote, because
|
||||||
|
// our suggested swap is beneath the minimum swap size.
|
||||||
|
c.autoloop(chan1Rec.Amount+1, chan1Rec.Amount+2, nil, nil, nil)
|
||||||
|
|
||||||
|
c.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestAutoLoopEnabled tests enabling the liquidity manger's autolooper. To keep
|
||||||
|
// the test simple, we do not update actual lnd channel balances, but rather
|
||||||
|
// run our mock with two channels that will always require a loop out according
|
||||||
|
// to our rules. This allows us to test the other restrictions placed on the
|
||||||
|
// autolooper (such as balance, and in-flight swaps) rather than need to worry
|
||||||
|
// about calculating swap amounts and thresholds.
|
||||||
|
func TestAutoLoopEnabled(t *testing.T) {
|
||||||
|
defer test.Guard(t)()
|
||||||
|
|
||||||
|
channels := []lndclient.ChannelInfo{
|
||||||
|
channel1, channel2,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a set of parameters with autoloop enabled. The autoloop budget
|
||||||
|
// is set to allow exactly 2 swaps at the prices that we set in our
|
||||||
|
// test quotes.
|
||||||
|
params := Parameters{
|
||||||
|
AutoOut: true,
|
||||||
|
AutoFeeBudget: 40066,
|
||||||
|
AutoFeeStartDate: testTime,
|
||||||
|
MaxAutoInFlight: 2,
|
||||||
|
FailureBackOff: time.Hour,
|
||||||
|
SweepFeeRateLimit: 20000,
|
||||||
|
SweepConfTarget: 10,
|
||||||
|
MaximumPrepay: 20000,
|
||||||
|
MaximumSwapFeePPM: 1000,
|
||||||
|
MaximumRoutingFeePPM: 1000,
|
||||||
|
MaximumPrepayRoutingFeePPM: 1000,
|
||||||
|
MaximumMinerFee: 20000,
|
||||||
|
ChannelRules: map[lnwire.ShortChannelID]*ThresholdRule{
|
||||||
|
chanID1: chanRule,
|
||||||
|
chanID2: chanRule,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
c := newAutoloopTestCtx(t, params, channels)
|
||||||
|
c.start()
|
||||||
|
|
||||||
|
// Calculate our maximum allowed fees and create quotes that fall within
|
||||||
|
// our budget.
|
||||||
|
var (
|
||||||
|
amt = chan1Rec.Amount
|
||||||
|
|
||||||
|
maxSwapFee = ppmToSat(amt, params.MaximumSwapFeePPM)
|
||||||
|
|
||||||
|
// Create a quote that is within our limits. We do not set miner
|
||||||
|
// fee because this value is not actually set by the server.
|
||||||
|
quote1 = &loop.LoopOutQuote{
|
||||||
|
SwapFee: maxSwapFee,
|
||||||
|
PrepayAmount: params.MaximumPrepay - 10,
|
||||||
|
}
|
||||||
|
|
||||||
|
quote2 = &loop.LoopOutQuote{
|
||||||
|
SwapFee: maxSwapFee,
|
||||||
|
PrepayAmount: params.MaximumPrepay - 20,
|
||||||
|
}
|
||||||
|
|
||||||
|
quoteRequest = &loop.LoopOutQuoteRequest{
|
||||||
|
Amount: amt,
|
||||||
|
SweepConfTarget: params.SweepConfTarget,
|
||||||
|
}
|
||||||
|
|
||||||
|
quotes = []quoteRequestResp{
|
||||||
|
{
|
||||||
|
request: quoteRequest,
|
||||||
|
quote: quote1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
request: quoteRequest,
|
||||||
|
quote: quote2,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
maxRouteFee = ppmToSat(amt, params.MaximumRoutingFeePPM)
|
||||||
|
|
||||||
|
chan1Swap = &loop.OutRequest{
|
||||||
|
Amount: amt,
|
||||||
|
MaxSwapRoutingFee: maxRouteFee,
|
||||||
|
MaxPrepayRoutingFee: ppmToSat(
|
||||||
|
quote1.PrepayAmount,
|
||||||
|
params.MaximumPrepayRoutingFeePPM,
|
||||||
|
),
|
||||||
|
MaxSwapFee: quote1.SwapFee,
|
||||||
|
MaxPrepayAmount: quote1.PrepayAmount,
|
||||||
|
MaxMinerFee: params.MaximumMinerFee,
|
||||||
|
SweepConfTarget: params.SweepConfTarget,
|
||||||
|
OutgoingChanSet: loopdb.ChannelSet{chanID1.ToUint64()},
|
||||||
|
Label: labels.AutoOutLabel(),
|
||||||
|
}
|
||||||
|
|
||||||
|
chan2Swap = &loop.OutRequest{
|
||||||
|
Amount: amt,
|
||||||
|
MaxSwapRoutingFee: maxRouteFee,
|
||||||
|
MaxPrepayRoutingFee: ppmToSat(
|
||||||
|
quote2.PrepayAmount,
|
||||||
|
params.MaximumPrepayRoutingFeePPM,
|
||||||
|
),
|
||||||
|
MaxSwapFee: quote2.SwapFee,
|
||||||
|
MaxPrepayAmount: quote2.PrepayAmount,
|
||||||
|
MaxMinerFee: params.MaximumMinerFee,
|
||||||
|
SweepConfTarget: params.SweepConfTarget,
|
||||||
|
OutgoingChanSet: loopdb.ChannelSet{chanID2.ToUint64()},
|
||||||
|
Label: labels.AutoOutLabel(),
|
||||||
|
}
|
||||||
|
|
||||||
|
loopOuts = []loopOutRequestResp{
|
||||||
|
{
|
||||||
|
request: chan1Swap,
|
||||||
|
response: &loop.LoopOutSwapInfo{
|
||||||
|
SwapHash: lntypes.Hash{1},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
request: chan2Swap,
|
||||||
|
response: &loop.LoopOutSwapInfo{
|
||||||
|
SwapHash: lntypes.Hash{2},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// Tick our autolooper with no existing swaps, we expect a loop out
|
||||||
|
// swap to be dispatched for each channel.
|
||||||
|
c.autoloop(1, amt+1, nil, quotes, loopOuts)
|
||||||
|
|
||||||
|
// Tick again with both of our swaps in progress. We haven't shifted our
|
||||||
|
// channel balances at all, so swaps should still be suggested, but we
|
||||||
|
// have 2 swaps in flight so we do not expect any suggestion.
|
||||||
|
existing := []*loopdb.LoopOut{
|
||||||
|
existingSwapFromRequest(chan1Swap, testTime, nil),
|
||||||
|
existingSwapFromRequest(chan2Swap, testTime, nil),
|
||||||
|
}
|
||||||
|
|
||||||
|
c.autoloop(1, amt+1, existing, nil, nil)
|
||||||
|
|
||||||
|
// Now, we update our channel 2 swap to have failed due to off chain
|
||||||
|
// failure and our first swap to have succeeded.
|
||||||
|
now := c.testClock.Now()
|
||||||
|
failedOffChain := []*loopdb.LoopEvent{
|
||||||
|
{
|
||||||
|
SwapStateData: loopdb.SwapStateData{
|
||||||
|
State: loopdb.StateFailOffchainPayments,
|
||||||
|
},
|
||||||
|
Time: now,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
success := []*loopdb.LoopEvent{
|
||||||
|
{
|
||||||
|
SwapStateData: loopdb.SwapStateData{
|
||||||
|
State: loopdb.StateSuccess,
|
||||||
|
Cost: loopdb.SwapCost{
|
||||||
|
Server: quote1.SwapFee,
|
||||||
|
Onchain: params.MaximumMinerFee,
|
||||||
|
Offchain: maxRouteFee +
|
||||||
|
chan1Rec.MaxPrepayRoutingFee,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Time: now,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
quotes = []quoteRequestResp{
|
||||||
|
{
|
||||||
|
request: quoteRequest,
|
||||||
|
quote: quote1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
loopOuts = []loopOutRequestResp{
|
||||||
|
{
|
||||||
|
request: chan1Swap,
|
||||||
|
response: &loop.LoopOutSwapInfo{
|
||||||
|
SwapHash: lntypes.Hash{3},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
existing = []*loopdb.LoopOut{
|
||||||
|
existingSwapFromRequest(chan1Swap, testTime, success),
|
||||||
|
existingSwapFromRequest(chan2Swap, testTime, failedOffChain),
|
||||||
|
}
|
||||||
|
|
||||||
|
// We tick again, this time we expect another swap on channel 1 (which
|
||||||
|
// still has balances which reflect that we need to swap), but nothing
|
||||||
|
// for channel 2, since it has had a failure.
|
||||||
|
c.autoloop(1, amt+1, existing, quotes, loopOuts)
|
||||||
|
|
||||||
|
// Now, we progress our time so that we have sufficiently backed off
|
||||||
|
// for channel 2, and could perform another swap.
|
||||||
|
c.testClock.SetTime(now.Add(params.FailureBackOff))
|
||||||
|
|
||||||
|
// Our existing swaps (1 successful, one pending) have used our budget
|
||||||
|
// so we no longer expect any swaps to automatically dispatch.
|
||||||
|
existing = []*loopdb.LoopOut{
|
||||||
|
existingSwapFromRequest(chan1Swap, testTime, success),
|
||||||
|
existingSwapFromRequest(chan1Swap, c.testClock.Now(), nil),
|
||||||
|
existingSwapFromRequest(chan2Swap, testTime, failedOffChain),
|
||||||
|
}
|
||||||
|
|
||||||
|
c.autoloop(1, amt+1, existing, quotes, nil)
|
||||||
|
|
||||||
|
c.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
// existingSwapFromRequest is a helper function which returns the db
|
||||||
|
// representation of a loop out request with the event set provided.
|
||||||
|
func existingSwapFromRequest(request *loop.OutRequest, initTime time.Time,
|
||||||
|
events []*loopdb.LoopEvent) *loopdb.LoopOut {
|
||||||
|
|
||||||
|
return &loopdb.LoopOut{
|
||||||
|
Loop: loopdb.Loop{
|
||||||
|
Events: events,
|
||||||
|
},
|
||||||
|
Contract: &loopdb.LoopOutContract{
|
||||||
|
SwapContract: loopdb.SwapContract{
|
||||||
|
AmountRequested: request.Amount,
|
||||||
|
MaxSwapFee: request.MaxSwapFee,
|
||||||
|
MaxMinerFee: request.MaxMinerFee,
|
||||||
|
InitiationTime: initTime,
|
||||||
|
Label: request.Label,
|
||||||
|
},
|
||||||
|
SwapInvoice: "",
|
||||||
|
MaxSwapRoutingFee: request.MaxSwapRoutingFee,
|
||||||
|
SweepConfTarget: request.SweepConfTarget,
|
||||||
|
OutgoingChanSet: request.OutgoingChanSet,
|
||||||
|
MaxPrepayRoutingFee: request.MaxSwapRoutingFee,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
211
liquidity/autoloop_testcontext_test.go
Normal file
211
liquidity/autoloop_testcontext_test.go
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
package liquidity
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/btcsuite/btcutil"
|
||||||
|
"github.com/lightninglabs/lndclient"
|
||||||
|
"github.com/lightninglabs/loop"
|
||||||
|
"github.com/lightninglabs/loop/loopdb"
|
||||||
|
"github.com/lightninglabs/loop/test"
|
||||||
|
"github.com/lightningnetwork/lnd/clock"
|
||||||
|
"github.com/lightningnetwork/lnd/ticker"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
)
|
||||||
|
|
||||||
|
type autoloopTestCtx struct {
|
||||||
|
t *testing.T
|
||||||
|
manager *Manager
|
||||||
|
lnd *test.LndMockServices
|
||||||
|
testClock *clock.TestClock
|
||||||
|
|
||||||
|
// quoteRequests is a channel that requests for quotes are pushed into.
|
||||||
|
quoteRequest chan *loop.LoopOutQuoteRequest
|
||||||
|
|
||||||
|
// quotes is a channel that we get loop out quote requests on.
|
||||||
|
quotes chan *loop.LoopOutQuote
|
||||||
|
|
||||||
|
// loopOutRestrictions is a channel that we get the server's
|
||||||
|
// restrictions on.
|
||||||
|
loopOutRestrictions chan *Restrictions
|
||||||
|
|
||||||
|
// loopOuts is a channel that we get existing loop out swaps on.
|
||||||
|
loopOuts chan []*loopdb.LoopOut
|
||||||
|
|
||||||
|
// loopIns is a channel that we get existing loop in swaps on.
|
||||||
|
loopIns chan []*loopdb.LoopIn
|
||||||
|
|
||||||
|
// restrictions is a channel that we get swap restrictions on.
|
||||||
|
restrictions chan *Restrictions
|
||||||
|
|
||||||
|
// outRequest is a channel that requests to dispatch loop outs are
|
||||||
|
// pushed into.
|
||||||
|
outRequest chan *loop.OutRequest
|
||||||
|
|
||||||
|
// loopOut is a channel that we return loop out responses on.
|
||||||
|
loopOut chan *loop.LoopOutSwapInfo
|
||||||
|
|
||||||
|
// errChan is a channel that we send run errors into.
|
||||||
|
errChan chan error
|
||||||
|
|
||||||
|
// cancelCtx cancels the context that our liquidity manager is run with.
|
||||||
|
// This can be used to cleanly shutdown the test. Note that this will be
|
||||||
|
// nil until the test context has been started.
|
||||||
|
cancelCtx func()
|
||||||
|
}
|
||||||
|
|
||||||
|
// newAutoloopTestCtx creates a test context with custom liquidity manager
|
||||||
|
// parameters and lnd channels.
|
||||||
|
func newAutoloopTestCtx(t *testing.T, parameters Parameters,
|
||||||
|
channels []lndclient.ChannelInfo) *autoloopTestCtx {
|
||||||
|
|
||||||
|
// Create a mock lnd and set our expected fee rate for sweeps to our
|
||||||
|
// sweep fee rate limit value.
|
||||||
|
lnd := test.NewMockLnd()
|
||||||
|
lnd.SetFeeEstimate(
|
||||||
|
defaultParameters.SweepConfTarget,
|
||||||
|
defaultParameters.SweepFeeRateLimit,
|
||||||
|
)
|
||||||
|
|
||||||
|
testCtx := &autoloopTestCtx{
|
||||||
|
t: t,
|
||||||
|
testClock: clock.NewTestClock(testTime),
|
||||||
|
lnd: lnd,
|
||||||
|
|
||||||
|
quoteRequest: make(chan *loop.LoopOutQuoteRequest),
|
||||||
|
quotes: make(chan *loop.LoopOutQuote),
|
||||||
|
loopOutRestrictions: make(chan *Restrictions),
|
||||||
|
loopOuts: make(chan []*loopdb.LoopOut),
|
||||||
|
loopIns: make(chan []*loopdb.LoopIn),
|
||||||
|
restrictions: make(chan *Restrictions),
|
||||||
|
outRequest: make(chan *loop.OutRequest),
|
||||||
|
loopOut: make(chan *loop.LoopOutSwapInfo),
|
||||||
|
|
||||||
|
errChan: make(chan error, 1),
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set lnd's channels to equal the set of channels we want for our
|
||||||
|
// test.
|
||||||
|
testCtx.lnd.Channels = channels
|
||||||
|
|
||||||
|
cfg := &Config{
|
||||||
|
AutoOutTicker: ticker.NewForce(DefaultAutoOutTicker),
|
||||||
|
LoopOutRestrictions: func(context.Context) (*Restrictions, error) {
|
||||||
|
return <-testCtx.loopOutRestrictions, nil
|
||||||
|
},
|
||||||
|
ListLoopOut: func() ([]*loopdb.LoopOut, error) {
|
||||||
|
return <-testCtx.loopOuts, nil
|
||||||
|
},
|
||||||
|
ListLoopIn: func() ([]*loopdb.LoopIn, error) {
|
||||||
|
return <-testCtx.loopIns, nil
|
||||||
|
},
|
||||||
|
LoopOutQuote: func(_ context.Context,
|
||||||
|
req *loop.LoopOutQuoteRequest) (*loop.LoopOutQuote,
|
||||||
|
error) {
|
||||||
|
|
||||||
|
testCtx.quoteRequest <- req
|
||||||
|
|
||||||
|
return <-testCtx.quotes, nil
|
||||||
|
},
|
||||||
|
LoopOut: func(_ context.Context,
|
||||||
|
req *loop.OutRequest) (*loop.LoopOutSwapInfo,
|
||||||
|
error) {
|
||||||
|
|
||||||
|
testCtx.outRequest <- req
|
||||||
|
|
||||||
|
return <-testCtx.loopOut, nil
|
||||||
|
},
|
||||||
|
MinimumConfirmations: loop.DefaultSweepConfTarget,
|
||||||
|
Lnd: &testCtx.lnd.LndServices,
|
||||||
|
Clock: testCtx.testClock,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create a manager with our test config and set our starting set of
|
||||||
|
// parameters.
|
||||||
|
testCtx.manager = NewManager(cfg)
|
||||||
|
assert.NoError(t, testCtx.manager.SetParameters(parameters))
|
||||||
|
|
||||||
|
return testCtx
|
||||||
|
}
|
||||||
|
|
||||||
|
// start starts our liquidity manager's run loop in a goroutine. Tests should
|
||||||
|
// be run with test.Guard() to ensure that this does not leak.
|
||||||
|
func (c *autoloopTestCtx) start() {
|
||||||
|
ctx := context.Background()
|
||||||
|
ctx, c.cancelCtx = context.WithCancel(ctx)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
c.errChan <- c.manager.Run(ctx)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// stop shuts down our test context and asserts that we have exited with a
|
||||||
|
// context-cancelled error.
|
||||||
|
func (c *autoloopTestCtx) stop() {
|
||||||
|
c.cancelCtx()
|
||||||
|
assert.Equal(c.t, context.Canceled, <-c.errChan)
|
||||||
|
}
|
||||||
|
|
||||||
|
// quoteRequestResp pairs an expected swap quote request with the response we
|
||||||
|
// would like to provide the liquidity manager with.
|
||||||
|
type quoteRequestResp struct {
|
||||||
|
request *loop.LoopOutQuoteRequest
|
||||||
|
quote *loop.LoopOutQuote
|
||||||
|
}
|
||||||
|
|
||||||
|
// loopOutRequestResp pairs an expected loop out request with the response we
|
||||||
|
// would like the server to respond with.
|
||||||
|
type loopOutRequestResp struct {
|
||||||
|
request *loop.OutRequest
|
||||||
|
response *loop.LoopOutSwapInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoloop walks our test context through the process of triggering our
|
||||||
|
// autoloop functionality, providing mocked values as required. The set of
|
||||||
|
// quotes provided indicates that we expect swap suggestions to be made (since
|
||||||
|
// we will query for a quote for each suggested swap). The set of expected
|
||||||
|
// swaps indicates whether we expect any of these swap suggestions to actually
|
||||||
|
// be dispatched by the autolooper.
|
||||||
|
func (c *autoloopTestCtx) autoloop(minAmt, maxAmt btcutil.Amount,
|
||||||
|
existingOut []*loopdb.LoopOut, quotes []quoteRequestResp,
|
||||||
|
expectedSwaps []loopOutRequestResp) {
|
||||||
|
|
||||||
|
// Tick our autoloop ticker to force assessing whether we want to loop.
|
||||||
|
c.manager.cfg.AutoOutTicker.Force <- testTime
|
||||||
|
|
||||||
|
// Send a mocked response from the server with the swap size limits.
|
||||||
|
c.loopOutRestrictions <- NewRestrictions(minAmt, maxAmt)
|
||||||
|
|
||||||
|
// Provide the liquidity manager with our desired existing set of swaps.
|
||||||
|
c.loopOuts <- existingOut
|
||||||
|
c.loopIns <- nil
|
||||||
|
|
||||||
|
// Assert that we query the server for a quote for each of our
|
||||||
|
// recommended swaps. Note that this differs from our set of expected
|
||||||
|
// swaps because we may get quotes for suggested swaps but then just
|
||||||
|
// log them.
|
||||||
|
for _, expected := range quotes {
|
||||||
|
request := <-c.quoteRequest
|
||||||
|
assert.Equal(
|
||||||
|
c.t, expected.request.Amount, request.Amount,
|
||||||
|
)
|
||||||
|
assert.Equal(
|
||||||
|
c.t, expected.request.SweepConfTarget,
|
||||||
|
request.SweepConfTarget,
|
||||||
|
)
|
||||||
|
c.quotes <- expected.quote
|
||||||
|
}
|
||||||
|
|
||||||
|
// Assert that we dispatch the expected set of swaps.
|
||||||
|
for _, expected := range expectedSwaps {
|
||||||
|
actual := <-c.outRequest
|
||||||
|
|
||||||
|
// Set our destination address to nil so that we do not need to
|
||||||
|
// provide the address that is obtained by the mock wallet kit.
|
||||||
|
actual.DestAddr = nil
|
||||||
|
|
||||||
|
assert.Equal(c.t, expected.request, actual)
|
||||||
|
c.loopOut <- expected.response
|
||||||
|
}
|
||||||
|
}
|
@ -51,6 +51,7 @@ import (
|
|||||||
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
|
"github.com/lightningnetwork/lnd/lnwallet/chainfee"
|
||||||
"github.com/lightningnetwork/lnd/lnwire"
|
"github.com/lightningnetwork/lnd/lnwire"
|
||||||
"github.com/lightningnetwork/lnd/routing/route"
|
"github.com/lightningnetwork/lnd/routing/route"
|
||||||
|
"github.com/lightningnetwork/lnd/ticker"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@ -93,7 +94,11 @@ const (
|
|||||||
// dispatched swaps we allow. Note that this does not enable automated
|
// dispatched swaps we allow. Note that this does not enable automated
|
||||||
// swaps itself (because we want non-zero values to be expressed in
|
// swaps itself (because we want non-zero values to be expressed in
|
||||||
// suggestions as a dry-run).
|
// suggestions as a dry-run).
|
||||||
defaultMaxInFlight = 2
|
defaultMaxInFlight = 1
|
||||||
|
|
||||||
|
// DefaultAutoOutTicker is the default amount of time between automated
|
||||||
|
// loop out checks.
|
||||||
|
DefaultAutoOutTicker = time.Minute * 10
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@ -158,6 +163,11 @@ var (
|
|||||||
// Config contains the external functionality required to run the
|
// Config contains the external functionality required to run the
|
||||||
// liquidity manager.
|
// liquidity manager.
|
||||||
type Config struct {
|
type Config struct {
|
||||||
|
// AutoOutTicker determines how often we should check whether we want
|
||||||
|
// to dispatch an automated loop out. We use a force ticker so that
|
||||||
|
// we can trigger autoloop in itests.
|
||||||
|
AutoOutTicker *ticker.Force
|
||||||
|
|
||||||
// LoopOutRestrictions returns the restrictions that the server applies
|
// LoopOutRestrictions returns the restrictions that the server applies
|
||||||
// to loop out swaps.
|
// to loop out swaps.
|
||||||
LoopOutRestrictions func(ctx context.Context) (*Restrictions, error)
|
LoopOutRestrictions func(ctx context.Context) (*Restrictions, error)
|
||||||
@ -176,6 +186,10 @@ type Config struct {
|
|||||||
LoopOutQuote func(ctx context.Context,
|
LoopOutQuote func(ctx context.Context,
|
||||||
request *loop.LoopOutQuoteRequest) (*loop.LoopOutQuote, error)
|
request *loop.LoopOutQuoteRequest) (*loop.LoopOutQuote, error)
|
||||||
|
|
||||||
|
// LoopOut dispatches a loop out.
|
||||||
|
LoopOut func(ctx context.Context, request *loop.OutRequest) (
|
||||||
|
*loop.LoopOutSwapInfo, 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
|
||||||
|
|
||||||
@ -187,6 +201,9 @@ type Config struct {
|
|||||||
// Parameters is a set of parameters provided by the user which guide
|
// Parameters is a set of parameters provided by the user which guide
|
||||||
// how we assess liquidity.
|
// how we assess liquidity.
|
||||||
type Parameters struct {
|
type Parameters struct {
|
||||||
|
// AutoOut enables automatic dispatch of loop out swaps.
|
||||||
|
AutoOut bool
|
||||||
|
|
||||||
// AutoFeeBudget is the total amount we allow to be spent on
|
// AutoFeeBudget is the total amount we allow to be spent on
|
||||||
// automatically dispatched swaps. Once this budget has been used, we
|
// automatically dispatched swaps. Once this budget has been used, we
|
||||||
// will stop dispatching swaps until the budget is increased or the
|
// will stop dispatching swaps until the budget is increased or the
|
||||||
@ -344,6 +361,24 @@ type Manager struct {
|
|||||||
paramsLock sync.Mutex
|
paramsLock sync.Mutex
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Run periodically checks whether we should automatically dispatch a loop out.
|
||||||
|
func (m *Manager) Run(ctx context.Context) error {
|
||||||
|
m.cfg.AutoOutTicker.Resume()
|
||||||
|
defer m.cfg.AutoOutTicker.Stop()
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-m.cfg.AutoOutTicker.Ticks():
|
||||||
|
if err := m.autoloop(ctx); err != nil {
|
||||||
|
log.Errorf("autoloop failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 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{
|
||||||
@ -392,10 +427,37 @@ func cloneParameters(params Parameters) Parameters {
|
|||||||
return paramCopy
|
return paramCopy
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// autoloop gets a set of suggested swaps and dispatches them automatically if
|
||||||
|
// we have automated looping enabled.
|
||||||
|
func (m *Manager) autoloop(ctx context.Context) error {
|
||||||
|
swaps, err := m.SuggestSwaps(ctx, true)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, swap := range swaps {
|
||||||
|
// Create a copy of our range var so that we can reference it.
|
||||||
|
swap := swap
|
||||||
|
loopOut, err := m.cfg.LoopOut(ctx, &swap)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Infof("loop out automatically dispatched: hash: %v, "+
|
||||||
|
"address: %v", loopOut.SwapHash,
|
||||||
|
loopOut.HtlcAddressP2WSH)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// SuggestSwaps returns a set of swap suggestions based on our current liquidity
|
// SuggestSwaps returns a set of swap suggestions based on our current liquidity
|
||||||
// balance for the set of rules configured for the manager, failing if there are
|
// balance for the set of rules configured for the manager, failing if there are
|
||||||
// no rules set.
|
// no rules set. It takes an autoOut boolean that indicates whether the
|
||||||
func (m *Manager) SuggestSwaps(ctx context.Context) (
|
// suggestions are being used for our internal autolooper. This boolean is used
|
||||||
|
// to determine the information we add to our swap suggestion and whether we
|
||||||
|
// return any suggestions.
|
||||||
|
func (m *Manager) SuggestSwaps(ctx context.Context, autoOut bool) (
|
||||||
[]loop.OutRequest, error) {
|
[]loop.OutRequest, error) {
|
||||||
|
|
||||||
m.paramsLock.Lock()
|
m.paramsLock.Lock()
|
||||||
@ -532,7 +594,12 @@ func (m *Manager) SuggestSwaps(ctx context.Context) (
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
outRequest := m.makeLoopOutRequest(suggestion, quote)
|
outRequest, err := m.makeLoopOutRequest(
|
||||||
|
ctx, suggestion, quote, autoOut,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
suggestions = append(suggestions, outRequest)
|
suggestions = append(suggestions, outRequest)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -575,6 +642,18 @@ func (m *Manager) SuggestSwaps(ctx context.Context) (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// If we are getting suggestions for automatically dispatched swaps,
|
||||||
|
// and they are not enabled in our parameters, we just log the swap
|
||||||
|
// suggestions and return an empty set of suggestions.
|
||||||
|
if autoOut && !m.params.AutoOut {
|
||||||
|
for _, swap := range inBudget {
|
||||||
|
log.Debugf("recommended autoloop: %v sats over "+
|
||||||
|
"%v", swap.Amount, swap.OutgoingChanSet)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
return inBudget, nil
|
return inBudget, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -585,9 +664,13 @@ func (m *Manager) SuggestSwaps(ctx context.Context) (
|
|||||||
// route-independent, which is a very poor estimation so we don't bother with
|
// 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
|
// 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
|
// 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.
|
// to give us some leeway when performing the swap. We take an auto-out which
|
||||||
func (m *Manager) makeLoopOutRequest(suggestion *LoopOutRecommendation,
|
// determines whether we set a label identifying this swap as automatically
|
||||||
quote *loop.LoopOutQuote) loop.OutRequest {
|
// dispatched, and decides whether we set a sweep address (we don't bother for
|
||||||
|
// non-auto requests, because the client api will set it anyway).
|
||||||
|
func (m *Manager) makeLoopOutRequest(ctx context.Context,
|
||||||
|
suggestion *LoopOutRecommendation, quote *loop.LoopOutQuote,
|
||||||
|
autoOut bool) (loop.OutRequest, error) {
|
||||||
|
|
||||||
prepayMaxFee := ppmToSat(
|
prepayMaxFee := ppmToSat(
|
||||||
quote.PrepayAmount, m.params.MaximumPrepayRoutingFeePPM,
|
quote.PrepayAmount, m.params.MaximumPrepayRoutingFeePPM,
|
||||||
@ -597,7 +680,7 @@ func (m *Manager) makeLoopOutRequest(suggestion *LoopOutRecommendation,
|
|||||||
suggestion.Amount, m.params.MaximumRoutingFeePPM,
|
suggestion.Amount, m.params.MaximumRoutingFeePPM,
|
||||||
)
|
)
|
||||||
|
|
||||||
return loop.OutRequest{
|
request := loop.OutRequest{
|
||||||
Amount: suggestion.Amount,
|
Amount: suggestion.Amount,
|
||||||
OutgoingChanSet: loopdb.ChannelSet{
|
OutgoingChanSet: loopdb.ChannelSet{
|
||||||
suggestion.Channel.ToUint64(),
|
suggestion.Channel.ToUint64(),
|
||||||
@ -609,6 +692,18 @@ func (m *Manager) makeLoopOutRequest(suggestion *LoopOutRecommendation,
|
|||||||
MaxPrepayAmount: quote.PrepayAmount,
|
MaxPrepayAmount: quote.PrepayAmount,
|
||||||
SweepConfTarget: m.params.SweepConfTarget,
|
SweepConfTarget: m.params.SweepConfTarget,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if autoOut {
|
||||||
|
request.Label = labels.AutoOutLabel()
|
||||||
|
|
||||||
|
addr, err := m.cfg.Lnd.WalletKit.NextAddr(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return loop.OutRequest{}, err
|
||||||
|
}
|
||||||
|
request.DestAddr = addr
|
||||||
|
}
|
||||||
|
|
||||||
|
return request, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// worstCaseOutFees calculates the largest possible fees for a loop out swap,
|
// worstCaseOutFees calculates the largest possible fees for a loop out swap,
|
||||||
|
@ -858,7 +858,7 @@ func testSuggestSwaps(t *testing.T, setup *testSuggestSwapsSetup,
|
|||||||
err := manager.SetParameters(setup.params)
|
err := manager.SetParameters(setup.params)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
actual, err := manager.SuggestSwaps(context.Background())
|
actual, err := manager.SuggestSwaps(context.Background(), false)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
require.Equal(t, expected, actual)
|
require.Equal(t, expected, actual)
|
||||||
}
|
}
|
||||||
|
@ -98,10 +98,10 @@ func New(config *Config, lisCfg *listenerCfg) *Daemon {
|
|||||||
cfg: config,
|
cfg: config,
|
||||||
listenerCfg: lisCfg,
|
listenerCfg: lisCfg,
|
||||||
|
|
||||||
// We have 3 goroutines that could potentially send an error.
|
// We have 4 goroutines that could potentially send an error.
|
||||||
// We react on the first error but in case more than one exits
|
// We react on the first error but in case more than one exits
|
||||||
// with an error we don't want them to block.
|
// with an error we don't want them to block.
|
||||||
internalErrChan: make(chan error, 3),
|
internalErrChan: make(chan error, 4),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -408,6 +408,19 @@ func (d *Daemon) initialize() error {
|
|||||||
d.processStatusUpdates(d.mainCtx)
|
d.processStatusUpdates(d.mainCtx)
|
||||||
}()
|
}()
|
||||||
|
|
||||||
|
d.wg.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer d.wg.Done()
|
||||||
|
|
||||||
|
log.Info("Starting liquidity manager")
|
||||||
|
err := d.liquidityMgr.Run(d.mainCtx)
|
||||||
|
if err != nil && err != context.Canceled {
|
||||||
|
d.internalErrChan <- err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info("Liquidity manager stopped")
|
||||||
|
}()
|
||||||
|
|
||||||
// Last, start our internal error handler. This will return exactly one
|
// Last, start our internal error handler. This will return exactly one
|
||||||
// error or nil on the main error channel to inform the caller that
|
// error or nil on the main error channel to inform the caller that
|
||||||
// something went wrong or that shutdown is complete. We don't add to
|
// something went wrong or that shutdown is complete. We don't add to
|
||||||
|
@ -672,7 +672,7 @@ func rpcToRule(rule *looprpc.LiquidityRule) (*liquidity.ThresholdRule, error) {
|
|||||||
func (s *swapClientServer) SuggestSwaps(ctx context.Context,
|
func (s *swapClientServer) SuggestSwaps(ctx context.Context,
|
||||||
_ *looprpc.SuggestSwapsRequest) (*looprpc.SuggestSwapsResponse, error) {
|
_ *looprpc.SuggestSwapsRequest) (*looprpc.SuggestSwapsResponse, error) {
|
||||||
|
|
||||||
swaps, err := s.liquidityMgr.SuggestSwaps(ctx)
|
swaps, err := s.liquidityMgr.SuggestSwaps(ctx, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
@ -8,6 +8,7 @@ import (
|
|||||||
"github.com/lightninglabs/loop"
|
"github.com/lightninglabs/loop"
|
||||||
"github.com/lightninglabs/loop/liquidity"
|
"github.com/lightninglabs/loop/liquidity"
|
||||||
"github.com/lightningnetwork/lnd/clock"
|
"github.com/lightningnetwork/lnd/clock"
|
||||||
|
"github.com/lightningnetwork/lnd/ticker"
|
||||||
)
|
)
|
||||||
|
|
||||||
// getClient returns an instance of the swap client.
|
// getClient returns an instance of the swap client.
|
||||||
@ -35,6 +36,8 @@ func getClient(config *Config, lnd *lndclient.LndServices) (*loop.Client,
|
|||||||
|
|
||||||
func getLiquidityManager(client *loop.Client) *liquidity.Manager {
|
func getLiquidityManager(client *loop.Client) *liquidity.Manager {
|
||||||
mngrCfg := &liquidity.Config{
|
mngrCfg := &liquidity.Config{
|
||||||
|
AutoOutTicker: ticker.NewForce(liquidity.DefaultAutoOutTicker),
|
||||||
|
LoopOut: client.LoopOut,
|
||||||
LoopOutRestrictions: func(ctx context.Context) (
|
LoopOutRestrictions: func(ctx context.Context) (
|
||||||
*liquidity.Restrictions, error) {
|
*liquidity.Restrictions, error) {
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user