mirror of https://github.com/lightninglabs/loop
multi: add opt-in automated swap dispatch to liquidity manager
parent
fd17580213
commit
8166d936e1
@ -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,
|
||||
},
|
||||
}
|
||||
}
|
@ -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
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue