2
0
mirror of https://github.com/lightninglabs/loop synced 2024-11-08 01:10:29 +00:00
loop/liquidity/autoloop_testcontext_test.go
Boris Nagaev 3be5a37cd3
liquidity: fix flaky autoloop test
This failure became normal recently:

=== RUN   TestAutoLoopInEnabled
    autoloop_testcontext_test.go:318:
        	Error Trace:	/home/runner/work/loop/loop/liquidity/autoloop_testcontext_test.go:318
        	            				/home/runner/work/loop/loop/liquidity/autoloop_test.go:804
        	Error:      	Not equal:
        	            	expected: 80000
        	            	actual  : 160000
        	Test:       	TestAutoLoopInEnabled
    autoloop_testcontext_test.go:318:
        	Error Trace:	/home/runner/work/loop/loop/liquidity/autoloop_testcontext_test.go:318
        	            				/home/runner/work/loop/loop/liquidity/autoloop_test.go:804
        	Error:      	Not equal:
        	            	expected: 160000
        	            	actual  : 80000
        	Test:       	TestAutoLoopInEnabled
    autoloop_testcontext_test.go:343:
        	Error Trace:	/home/runner/work/loop/loop/liquidity/autoloop_testcontext_test.go:343
        	            				/home/runner/work/loop/loop/liquidity/autoloop_test.go:804
        	Error:      	Should be true
        	Test:       	TestAutoLoopInEnabled

The root cause is them the order of items in c.quoteRequestIn depends on the
order of loopInBuilder.buildSwap calls, which depends on the order of channel
handling in Manager.SuggestSwaps, which depends on the order of map traversal,
which is not determitistic.

Since in the test all the amounts are different, I used amount as a key and
put the expected calls into a map using amount as a key. When I extract an
item from c.quoteRequestIn channel, I find the corresponding item in the map
and remove it. All other logic is preserved.
2024-05-30 15:41:51 -03:00

518 lines
15 KiB
Go

package liquidity
import (
"context"
"reflect"
"testing"
"time"
"github.com/btcsuite/btcd/btcutil"
"github.com/lightninglabs/lndclient"
"github.com/lightninglabs/loop"
"github.com/lightninglabs/loop/loopdb"
"github.com/lightninglabs/loop/swap"
"github.com/lightninglabs/loop/test"
"github.com/lightningnetwork/lnd/clock"
"github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/ticker"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
const (
defaultEventuallyTimeout = time.Second * 45
defaultEventuallyInterval = time.Millisecond * 100
)
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
// quoteRequestIn is a channel that requests for loop in quotes are
// pushed into.
quoteRequestIn chan *loop.LoopInQuoteRequest
// quotesIn is a channel that we get loop in quote responses on.
quotesIn chan *loop.LoopInQuote
// loopOutRestrictions is a channel that we get the server's
// restrictions on.
loopOutRestrictions chan *Restrictions
// loopInRestrictions is a channel that we get the server's
// loop in restrictions on.
loopInRestrictions chan *Restrictions
// loopOuts is a channel that we get existing loop out swaps on.
loopOuts chan []*loopdb.LoopOut
// loopOutSingle is the single loop out returned from fetching a single
// swap from store.
loopOutSingle *loopdb.LoopOut
// loopIns is a channel that we get existing loop in swaps on.
loopIns chan []*loopdb.LoopIn
// loopInSingle is the single loop in returned from fetching a single
// swap from store.
loopInSingle *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
// inRequest is a channel that requests to dispatch loop in swaps are
// pushed into.
inRequest chan *loop.LoopInRequest
// loopIn is a channel that we return loop in responses on.
loopIn chan *loop.LoopInSwapInfo
// 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,
server *Restrictions) *autoloopTestCtx {
// Create a mock lnd and set our expected fee rate for sweeps to our
// sweep fee rate limit value.
lnd := test.NewMockLnd()
categories, ok := parameters.FeeLimit.(*FeeCategoryLimit)
if ok {
lnd.SetFeeEstimate(
parameters.SweepConfTarget, categories.SweepFeeRateLimit,
)
}
testCtx := &autoloopTestCtx{
t: t,
testClock: clock.NewTestClock(testTime),
lnd: lnd,
quoteRequest: make(chan *loop.LoopOutQuoteRequest),
quotes: make(chan *loop.LoopOutQuote),
quoteRequestIn: make(chan *loop.LoopInQuoteRequest),
quotesIn: make(chan *loop.LoopInQuote),
loopOutRestrictions: make(chan *Restrictions),
loopInRestrictions: 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),
inRequest: make(chan *loop.LoopInRequest),
loopIn: make(chan *loop.LoopInSwapInfo),
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{
AutoloopTicker: ticker.NewForce(DefaultAutoloopTicker),
Restrictions: func(_ context.Context, swapType swap.Type, initiator string) (*Restrictions,
error) {
if swapType == swap.TypeOut {
return <-testCtx.loopOutRestrictions, nil
}
return <-testCtx.loopInRestrictions, nil
},
ListLoopOut: func(context.Context) ([]*loopdb.LoopOut, error) {
return <-testCtx.loopOuts, nil
},
GetLoopOut: func(ctx context.Context,
hash lntypes.Hash) (*loopdb.LoopOut, error) {
return testCtx.loopOutSingle, nil
},
ListLoopIn: func(context.Context) ([]*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
},
LoopInQuote: func(_ context.Context,
req *loop.LoopInQuoteRequest) (*loop.LoopInQuote, error) {
testCtx.quoteRequestIn <- req
return <-testCtx.quotesIn, nil
},
LoopIn: func(_ context.Context,
req *loop.LoopInRequest) (*loop.LoopInSwapInfo, error) {
testCtx.inRequest <- req
return <-testCtx.loopIn, nil
},
MinimumConfirmations: loop.DefaultSweepConfTarget,
Lnd: &testCtx.lnd.LndServices,
Clock: testCtx.testClock,
PutLiquidityParams: func(_ context.Context, _ []byte) error {
return nil
},
FetchLiquidityParams: func(context.Context) ([]byte, error) {
return nil, nil
},
}
// SetParameters needs to make a call to our mocked restrictions call,
// which will block, so we push our test values in a goroutine.
done := make(chan struct{})
go func() {
testCtx.loopOutRestrictions <- server
close(done)
}()
// Create a manager with our test config and set our starting set of
// parameters.
testCtx.manager = NewManager(cfg)
err := testCtx.manager.setParameters(context.Background(), parameters)
assert.NoError(t, err)
// Override the payments check interval for the tests in order to not
// timeout.
testCtx.manager.params.CustomPaymentCheckInterval =
150 * time.Millisecond
<-done
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
}
// quoteInRequestResp pairs an expected loop in quote request with the response
// we would like to provide the manager with.
type quoteInRequestResp struct {
request *loop.LoopInQuoteRequest
quote *loop.LoopInQuote
}
// loopInRequestResp pairs and expected loop in request with the response we
// would like the mocked server to respond with.
type loopInRequestResp struct {
request *loop.LoopInRequest
response *loop.LoopInSwapInfo
}
// autoloopStep contains all of the information to required to step
// through an autoloop tick.
type autoloopStep struct {
minAmt btcutil.Amount
maxAmt btcutil.Amount
existingOut []*loopdb.LoopOut
existingOutSingle *loopdb.LoopOut
existingIn []*loopdb.LoopIn
existingInSingle *loopdb.LoopIn
quotesOut []quoteRequestResp
quotesIn []quoteInRequestResp
expectedOut []loopOutRequestResp
expectedIn []loopInRequestResp
keepDestAddr bool
}
type easyAutoloopStep struct {
minAmt btcutil.Amount
maxAmt btcutil.Amount
existingOut []*loopdb.LoopOut
existingIn []*loopdb.LoopIn
quotesOut []quoteRequestResp
expectedOut []loopOutRequestResp
}
// 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(step *autoloopStep) {
// Tick our autoloop ticker to force assessing whether we want to loop.
c.manager.cfg.AutoloopTicker.Force <- testTime
// Send a mocked response from the server with the swap size limits.
c.loopOutRestrictions <- NewRestrictions(step.minAmt, step.maxAmt)
c.loopInRestrictions <- NewRestrictions(step.minAmt, step.maxAmt)
// Provide the liquidity manager with our desired existing set of swaps.
c.loopOuts <- step.existingOut
c.loopIns <- step.existingIn
c.loopOutSingle = step.existingOutSingle
c.loopInSingle = step.existingInSingle
// 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. The order in c.quoteRequestIn is not deterministic,
// it depends on the order of map traversal (map peerChannels in
// method Manager.SuggestSwaps). So receive from the channel an item
// and then find a corresponding expected item, using amount as a key.
amt2expected := make(map[btcutil.Amount]quoteInRequestResp)
for _, expected := range step.quotesIn {
// Make sure all amounts are unique.
require.NotContains(c.t, amt2expected, expected.request.Amount)
amt2expected[expected.request.Amount] = expected
}
for i := 0; i < len(step.quotesIn); i++ {
request := <-c.quoteRequestIn
// Get the expected item, using amount as a key.
expected, has := amt2expected[request.Amount]
require.True(c.t, has)
delete(amt2expected, request.Amount)
assert.Equal(
c.t, expected.request.Amount, request.Amount,
)
assert.Equal(
c.t, expected.request.HtlcConfTarget,
request.HtlcConfTarget,
)
c.quotesIn <- expected.quote
}
for _, expected := range step.quotesOut {
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
}
require.True(c.t, c.matchLoopOuts(step.expectedOut, step.keepDestAddr))
require.True(c.t, c.matchLoopIns(step.expectedIn))
require.Eventuallyf(c.t, func() bool {
return c.manager.numActiveStickyLoops() == 0
}, defaultEventuallyTimeout, defaultEventuallyInterval, "failed to"+
" wait for sticky loop counter")
// Since we're checking if any false-positive swaps were dispatched we
// need to give some time to autoloop to possibly dispatch them.
select {
case <-c.outRequest:
c.t.Fatal("expected no more loopout requests")
case <-c.inRequest:
c.t.Fatal("expected no more loopin requests")
case <-c.quoteRequestIn:
c.t.Fatal("expected no more loopout quote requests")
case <-c.quoteRequest:
c.t.Fatal("expected no more loopin quote requests")
case <-time.After(500 * time.Millisecond):
}
}
// easyautoloop walks our test context through the process of triggering our
// easy autoloop functionality, providing mocked values as required. The number
// of values needed to mock easy autoloop are less than standard autoloop as the
// goal of easy autoloop is to simplify its usage.
func (c *autoloopTestCtx) easyautoloop(step *easyAutoloopStep, noop bool) {
// Tick our autoloop ticker to force assessing whether we want to loop.
c.manager.cfg.AutoloopTicker.Force <- testTime
// Provide the liquidity manager with our desired existing set of swaps.
c.loopOuts <- step.existingOut
c.loopIns <- step.existingIn
// If easy autoloop is not meant to be triggered we skip sending the
// mock response for restrictions, as this is never called.
if !noop {
// Send a mocked response from the server with the swap size limits.
c.loopOutRestrictions <- NewRestrictions(step.minAmt, step.maxAmt)
}
for _, expected := range step.quotesOut {
request := <-c.quoteRequest
require.Equal(
c.t, expected.request.Amount, request.Amount,
)
c.quotes <- expected.quote
}
for _, expected := range step.expectedOut {
actual := <-c.outRequest
require.Equal(c.t, expected.request.Amount, actual.Amount)
require.Equal(
c.t, expected.request.OutgoingChanSet,
actual.OutgoingChanSet,
)
if expected.request.DestAddr != nil {
require.Equal(
c.t, expected.request.DestAddr, actual.DestAddr,
)
}
}
// Since we're checking if any false-positive swaps were dispatched we
// need to give some time to autoloop to possibly dispatch them.
select {
case <-c.outRequest:
c.t.Fatal("expected no more loopout requests")
case <-c.inRequest:
c.t.Fatal("expected no more loopin requests")
case <-c.quoteRequestIn:
c.t.Fatal("expected no more loopout quote requests")
case <-c.quoteRequest:
c.t.Fatal("expected no more loopin quote requests")
case <-time.After(500 * time.Millisecond):
}
}
// matchLoopOuts checks that the actual loop out requests we got match the
// expected ones. The argument keepDestAddr is used to indicate whether we keep
// the actual loops destination address for the comparison. This is useful
// because we don't want to compare the destination address generated by the
// wallet mock. We want to compare the destination address when testing the
// autoloop DestAddr parameter for loop outs.
func (c *autoloopTestCtx) matchLoopOuts(swaps []loopOutRequestResp,
keepDestAddr bool) bool {
swapsCopy := make([]loopOutRequestResp, len(swaps))
copy(swapsCopy, swaps)
length := len(swapsCopy)
for i := 0; i < length; i++ {
actual := <-c.outRequest
if !keepDestAddr {
actual.DestAddr = nil
}
inner:
for index, swap := range swapsCopy {
equal := reflect.DeepEqual(swap.request, actual)
if equal {
c.loopOut <- swap.response
swapsCopy = append(
swapsCopy[:index],
swapsCopy[index+1:]...,
)
break inner
}
}
}
return len(swapsCopy) == 0
}
// matchLoopIns checks that the actual loop in requests we got match the
// expected ones.
func (c *autoloopTestCtx) matchLoopIns(
swaps []loopInRequestResp) bool {
swapsCopy := make([]loopInRequestResp, len(swaps))
copy(swapsCopy, swaps)
for i := 0; i < len(swapsCopy); i++ {
actual := <-c.inRequest
inner:
for i, swap := range swapsCopy {
equal := reflect.DeepEqual(swap.request, actual)
if equal {
c.loopIn <- swap.response
swapsCopy = append(
swapsCopy[:i], swapsCopy[i+1:]...,
)
break inner
}
}
}
return len(swapsCopy) == 0
}