package liquidity import ( "context" "testing" "time" "github.com/btcsuite/btcd/btcutil" "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop" "github.com/lightninglabs/loop/labels" "github.com/lightninglabs/loop/loopdb" clientrpc "github.com/lightninglabs/loop/looprpc" "github.com/lightninglabs/loop/swap" "github.com/lightninglabs/loop/test" "github.com/lightningnetwork/lnd/clock" "github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/routing/route" "github.com/stretchr/testify/mock" "github.com/stretchr/testify/require" ) var ( testTime = time.Date(2020, 02, 13, 0, 0, 0, 0, time.UTC) testBudgetStart = testTime.Add(time.Hour * -1) // In order to not influence existing tests we set the budget refresh // period to 10 years so that it will never be refreshed. This way the // behavior of autoloop remains identical to before recurring budget was // introduced. testBudgetRefresh = time.Hour * 24 * 365 * 10 chanID1 = lnwire.NewShortChanIDFromInt(1) chanID2 = lnwire.NewShortChanIDFromInt(2) chanID3 = lnwire.NewShortChanIDFromInt(3) peer1 = route.Vertex{1} peer2 = route.Vertex{2} channel1 = lndclient.ChannelInfo{ ChannelID: chanID1.ToUint64(), PubKeyBytes: peer1, LocalBalance: 10000, RemoteBalance: 0, Capacity: 10000, } channel2 = lndclient.ChannelInfo{ ChannelID: chanID2.ToUint64(), PubKeyBytes: peer2, LocalBalance: 10000, RemoteBalance: 0, Capacity: 10000, } // chanRule is a rule that produces chan1Rec. chanRule = &SwapRule{ ThresholdRule: NewThresholdRule(50, 0), Type: swap.TypeOut, } testQuote = &loop.LoopOutQuote{ SwapFee: btcutil.Amount(5), PrepayAmount: btcutil.Amount(50), MinerFee: btcutil.Amount(1), } prepayFee, routingFee = testPPMFees(defaultFeePPM, testQuote, 7500) // chan1Rec is the suggested swap for channel 1 when we use chanRule. chan1Rec = loop.OutRequest{ Amount: 7500, OutgoingChanSet: loopdb.ChannelSet{chanID1.ToUint64()}, MaxPrepayRoutingFee: prepayFee, MaxSwapRoutingFee: routingFee, MaxMinerFee: scaleMaxMinerFee( scaleMinerFee(testQuote.MinerFee), ), MaxSwapFee: testQuote.SwapFee, MaxPrepayAmount: testQuote.PrepayAmount, SweepConfTarget: defaultConfTarget, Initiator: autoloopSwapInitiator, } // chan2Rec is the suggested swap for channel 2 when we use chanRule. chan2Rec = loop.OutRequest{ Amount: 7500, OutgoingChanSet: loopdb.ChannelSet{chanID2.ToUint64()}, MaxPrepayRoutingFee: prepayFee, MaxSwapRoutingFee: routingFee, MaxMinerFee: scaleMaxMinerFee( scaleMinerFee(testQuote.MinerFee), ), MaxPrepayAmount: testQuote.PrepayAmount, MaxSwapFee: testQuote.SwapFee, SweepConfTarget: defaultConfTarget, Initiator: autoloopSwapInitiator, } // chan1Out is a contract that uses channel 1, used to represent on // disk swap using chan 1. chan1Out = &loopdb.LoopOutContract{ OutgoingChanSet: loopdb.ChannelSet( []uint64{ chanID1.ToUint64(), }, ), } // autoOutContract is a contract for an existing loop out that was // automatically dispatched. This swap is within our test budget period, // and restricted to a channel that we do not use in our tests. autoOutContract = &loopdb.LoopOutContract{ SwapContract: loopdb.SwapContract{ Label: labels.AutoloopLabel(swap.TypeOut), InitiationTime: testBudgetStart, }, OutgoingChanSet: loopdb.ChannelSet{999}, } autoInContract = &loopdb.LoopInContract{ SwapContract: loopdb.SwapContract{ Label: labels.AutoloopLabel(swap.TypeIn), InitiationTime: testBudgetStart, }, } testRestrictions = NewRestrictions(1, 10000) // noneDisqualified can be used in tests where we don't have any // disqualified channels so that we can use require.Equal. noneDisqualified = make(map[lnwire.ShortChannelID]Reason) // noPeersDisqualified can be used in tests where we don't have any // disqualified peers so that we can use require.Equal. noPeersDisqualified = make(map[route.Vertex]Reason) ) // newTestConfig creates a default test config. func newTestConfig() (*Config, *test.LndMockServices) { lnd := test.NewMockLnd() // Set our fee estimate for the default number of confirmations to our // limit so that our fees will be ok by default. lnd.SetFeeEstimate( defaultParameters.SweepConfTarget, defaultSweepFeeRateLimit, ) return &Config{ Restrictions: func(_ context.Context, _ swap.Type, initiator string) (*Restrictions, error) { return testRestrictions, nil }, Lnd: &lnd.LndServices, Clock: clock.NewTestClock(testTime), ListLoopOut: func(context.Context) ([]*loopdb.LoopOut, error) { return nil, nil }, ListLoopIn: func(context.Context) ([]*loopdb.LoopIn, error) { return nil, nil }, LoopOutQuote: func(_ context.Context, _ *loop.LoopOutQuoteRequest) (*loop.LoopOutQuote, error) { return testQuote, nil }, }, lnd } // testPPMFees calculates the split of fees between prepay and swap invoice // for the swap amount and ppm, relying on the test quote. func testPPMFees(ppm uint64, quote *loop.LoopOutQuote, swapAmount btcutil.Amount) (btcutil.Amount, btcutil.Amount) { feeTotal := ppmToSat(swapAmount, ppm) feeAvailable := feeTotal - scaleMinerFee(quote.MinerFee) - quote.SwapFee return splitOffChain( feeAvailable, quote.PrepayAmount, swapAmount, ) } // applyFeeCategoryQuote returns a copy of the loop out request provided with // fee categories updated to the quote and routing settings provided. // nolint:unparam func applyFeeCategoryQuote(req loop.OutRequest, minerFee btcutil.Amount, prepayPPM, routingPPM uint64, quote loop.LoopOutQuote) loop.OutRequest { req.MaxPrepayRoutingFee = ppmToSat(quote.PrepayAmount, prepayPPM) req.MaxSwapRoutingFee = ppmToSat(req.Amount, routingPPM) req.MaxSwapFee = quote.SwapFee req.MaxPrepayAmount = quote.PrepayAmount req.MaxMinerFee = minerFee return req } // TestParameters tests getting and setting of parameters for our manager. func TestParameters(t *testing.T) { cfg, _ := newTestConfig() manager := NewManager(cfg) chanID := lnwire.NewShortChanIDFromInt(1) // Start with the case where we have no rules set. startParams := manager.GetParameters() require.Equal(t, defaultParameters, startParams) // Mutate the parameters returned by our get function. startParams.ChannelRules[chanID] = &SwapRule{ ThresholdRule: NewThresholdRule(1, 1), Type: swap.TypeOut, } // Make sure that we have not mutated the liquidity manager's params // by making this change. params := manager.GetParameters() require.Equal(t, defaultParameters, params) // Provide a valid set of parameters and validate assert that they are // set. originalRule := &SwapRule{ ThresholdRule: NewThresholdRule(10, 10), Type: swap.TypeOut, } expected := defaultParameters expected.ChannelRules = map[lnwire.ShortChannelID]*SwapRule{ chanID: originalRule, } err := manager.setParameters(context.Background(), expected) require.NoError(t, err) // Check that changing the parameters we just set does not mutate // our liquidity manager's parameters. expected.ChannelRules[chanID] = &SwapRule{ ThresholdRule: NewThresholdRule(11, 11), Type: swap.TypeOut, } params = manager.GetParameters() require.NoError(t, err) require.Equal(t, originalRule, params.ChannelRules[chanID]) // Set invalid parameters and assert that we fail. expected.ChannelRules = map[lnwire.ShortChannelID]*SwapRule{ lnwire.NewShortChanIDFromInt(0): { ThresholdRule: NewThresholdRule(1, 2), Type: swap.TypeOut, }, } err = manager.setParameters(context.Background(), expected) require.Equal(t, ErrZeroChannelID, err) } // TestPersistParams tests reading and writing of parameters for our manager. func TestPersistParams(t *testing.T) { rpcParams := &clientrpc.LiquidityParameters{ FeePpm: 100, AutoMaxInFlight: 10, HtlcConfTarget: 2, } cfg, _ := newTestConfig() manager := NewManager(cfg) ctxb := context.Background() var paramsBytes []byte // Mock the read method to return empty data. manager.cfg.FetchLiquidityParams = func(context.Context) ([]byte, error) { return paramsBytes, nil } // Test the nil params is returned. req, err := manager.loadParams(ctxb) require.Nil(t, req) require.NoError(t, err) // Mock the write method to return no error. manager.cfg.PutLiquidityParams = func(ctx context.Context, data []byte) error { paramsBytes = data return nil } // Test save the message. err = manager.saveParams(ctxb, rpcParams) require.NoError(t, err) // Test the nil params is returned. req, err = manager.loadParams(ctxb) require.NoError(t, err) // Check the specified fields are set as expected. require.Equal(t, rpcParams.FeePpm, req.FeePpm) require.Equal(t, rpcParams.AutoMaxInFlight, req.AutoMaxInFlight) require.Equal(t, rpcParams.HtlcConfTarget, req.HtlcConfTarget) // Check the unspecified fields are using empty values. require.False(t, req.Autoloop) require.Empty(t, req.Rules) require.Zero(t, req.AutoloopBudgetSat) // Finally, check the loaded request can be used to set params without // error. err = manager.SetParameters(context.Background(), req) require.NoError(t, err) } // TestRestrictedSuggestions tests getting of swap suggestions when we have // other in-flight swaps. We setup our manager with a set of channels and rules // that require a loop out swap, focusing on the filtering our of channels that // are in use for in-flight swaps, or those which have recently failed. func TestRestrictedSuggestions(t *testing.T) { var ( failedWithinTimeout = &loopdb.LoopEvent{ SwapStateData: loopdb.SwapStateData{ State: loopdb.StateFailOffchainPayments, }, Time: testTime, } failedBeforeBackoff = &loopdb.LoopEvent{ SwapStateData: loopdb.SwapStateData{ State: loopdb.StateFailOffchainPayments, }, Time: testTime.Add( defaultFailureBackoff * -1, ), } // failedTemporary is a swap that failed outside of our backoff // period, but we still want to back off because the swap is // considered pending. failedTemporary = &loopdb.LoopEvent{ SwapStateData: loopdb.SwapStateData{ State: loopdb.StateFailTemporary, }, Time: testTime.Add( defaultFailureBackoff * -3, ), } chanRules = map[lnwire.ShortChannelID]*SwapRule{ chanID1: chanRule, chanID2: chanRule, } ) tests := []struct { name string channels []lndclient.ChannelInfo loopOut []*loopdb.LoopOut loopIn []*loopdb.LoopIn chanRules map[lnwire.ShortChannelID]*SwapRule peerRules map[route.Vertex]*SwapRule expected *Suggestions }{ { name: "no existing swaps", channels: []lndclient.ChannelInfo{ channel1, }, loopOut: nil, loopIn: nil, chanRules: chanRules, expected: &Suggestions{ OutSwaps: []loop.OutRequest{ chan1Rec, }, DisqualifiedChans: noneDisqualified, DisqualifiedPeers: noPeersDisqualified, }, }, { name: "unrestricted loop out", channels: []lndclient.ChannelInfo{ channel1, }, loopOut: []*loopdb.LoopOut{ { Contract: &loopdb.LoopOutContract{ OutgoingChanSet: nil, }, }, }, chanRules: chanRules, expected: &Suggestions{ OutSwaps: []loop.OutRequest{ chan1Rec, }, DisqualifiedChans: noneDisqualified, DisqualifiedPeers: noPeersDisqualified, }, }, { name: "unrestricted loop in", channels: []lndclient.ChannelInfo{ channel1, }, loopIn: []*loopdb.LoopIn{ { Contract: &loopdb.LoopInContract{ LastHop: nil, }, }, }, chanRules: chanRules, expected: &Suggestions{ OutSwaps: []loop.OutRequest{ chan1Rec, }, DisqualifiedChans: noneDisqualified, DisqualifiedPeers: noPeersDisqualified, }, }, { name: "restricted loop out", channels: []lndclient.ChannelInfo{ channel1, channel2, }, loopOut: []*loopdb.LoopOut{ { Contract: chan1Out, }, }, chanRules: chanRules, expected: &Suggestions{ OutSwaps: []loop.OutRequest{ chan2Rec, }, DisqualifiedChans: map[lnwire.ShortChannelID]Reason{ chanID1: ReasonLoopOut, }, DisqualifiedPeers: noPeersDisqualified, }, }, { name: "restricted loop in", channels: []lndclient.ChannelInfo{ channel1, channel2, }, loopIn: []*loopdb.LoopIn{ { Contract: &loopdb.LoopInContract{ LastHop: &peer2, }, }, }, chanRules: chanRules, expected: &Suggestions{ OutSwaps: []loop.OutRequest{ chan1Rec, }, DisqualifiedChans: map[lnwire.ShortChannelID]Reason{ chanID2: ReasonLoopIn, }, DisqualifiedPeers: noPeersDisqualified, }, }, { name: "swap failed recently", channels: []lndclient.ChannelInfo{ channel1, }, loopOut: []*loopdb.LoopOut{ { Contract: chan1Out, Loop: loopdb.Loop{ Events: []*loopdb.LoopEvent{ failedWithinTimeout, }, }, }, }, chanRules: chanRules, expected: &Suggestions{ DisqualifiedChans: map[lnwire.ShortChannelID]Reason{ chanID1: ReasonFailureBackoff, }, DisqualifiedPeers: noPeersDisqualified, }, }, { name: "swap failed before cutoff", channels: []lndclient.ChannelInfo{ channel1, }, loopOut: []*loopdb.LoopOut{ { Contract: chan1Out, Loop: loopdb.Loop{ Events: []*loopdb.LoopEvent{ failedBeforeBackoff, }, }, }, }, chanRules: chanRules, expected: &Suggestions{ OutSwaps: []loop.OutRequest{ chan1Rec, }, DisqualifiedChans: noneDisqualified, DisqualifiedPeers: noPeersDisqualified, }, }, { name: "temporary failure", channels: []lndclient.ChannelInfo{ channel1, }, loopOut: []*loopdb.LoopOut{ { Contract: chan1Out, Loop: loopdb.Loop{ Events: []*loopdb.LoopEvent{ failedTemporary, }, }, }, }, chanRules: chanRules, expected: &Suggestions{ DisqualifiedChans: map[lnwire.ShortChannelID]Reason{ chanID1: ReasonLoopOut, }, DisqualifiedPeers: noPeersDisqualified, }, }, { name: "existing on peer's channel", channels: []lndclient.ChannelInfo{ channel1, { ChannelID: chanID3.ToUint64(), PubKeyBytes: peer1, }, }, loopOut: []*loopdb.LoopOut{ { Contract: chan1Out, }, }, peerRules: map[route.Vertex]*SwapRule{ peer1: { ThresholdRule: NewThresholdRule(0, 50), Type: swap.TypeOut, }, }, expected: &Suggestions{ DisqualifiedChans: noneDisqualified, DisqualifiedPeers: map[route.Vertex]Reason{ peer1: ReasonLoopOut, }, }, }, } for _, testCase := range tests { testCase := testCase t.Run(testCase.name, func(t *testing.T) { // Create a manager config which will return the test // case's set of existing swaps. cfg, lnd := newTestConfig() cfg.ListLoopOut = func(context.Context) ([]*loopdb.LoopOut, error) { return testCase.loopOut, nil } cfg.ListLoopIn = func(context.Context) ([]*loopdb.LoopIn, error) { return testCase.loopIn, nil } lnd.Channels = testCase.channels params := defaultParameters params.AutoloopBudgetLastRefresh = testBudgetStart if testCase.chanRules != nil { params.ChannelRules = testCase.chanRules } if testCase.peerRules != nil { params.PeerRules = testCase.peerRules } testSuggestSwaps( t, newSuggestSwapsSetup(cfg, lnd, params), testCase.expected, nil, ) }) } } // TestSweepFeeLimit tests getting of swap suggestions when our estimated sweep // fee is above and below the configured limit. func TestSweepFeeLimit(t *testing.T) { quote := &loop.LoopOutQuote{ SwapFee: btcutil.Amount(1), PrepayAmount: btcutil.Amount(500), MinerFee: btcutil.Amount(50), } tests := []struct { name string feeRate chainfee.SatPerKWeight suggestions *Suggestions }{ { name: "fee estimate ok", feeRate: defaultSweepFeeRateLimit, suggestions: &Suggestions{ OutSwaps: []loop.OutRequest{ applyFeeCategoryQuote( chan1Rec, defaultMaximumMinerFee, defaultPrepayRoutingFeePPM, defaultRoutingFeePPM, *quote, ), }, DisqualifiedChans: noneDisqualified, DisqualifiedPeers: noPeersDisqualified, }, }, { name: "fee estimate above limit", feeRate: defaultSweepFeeRateLimit + 1, suggestions: &Suggestions{ DisqualifiedChans: map[lnwire.ShortChannelID]Reason{ chanID1: ReasonSweepFees, }, DisqualifiedPeers: noPeersDisqualified, }, }, } 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 quote, nil } // Set our test case's fee rate for our mock lnd. lnd.SetFeeEstimate( defaultConfTarget, testCase.feeRate, ) lnd.Channels = []lndclient.ChannelInfo{ channel1, } params := defaultParameters params.AutoloopBudgetLastRefresh = testBudgetStart params.FeeLimit = defaultFeeCategoryLimit() // Set our budget to cover a single swap with these // parameters. params.AutoFeeBudget = defaultMaximumMinerFee + ppmToSat(7500, defaultSwapFeePPM) + ppmToSat(7500, defaultPrepayRoutingFeePPM) + ppmToSat(7500, defaultRoutingFeePPM) params.ChannelRules = map[lnwire.ShortChannelID]*SwapRule{ chanID1: chanRule, } testSuggestSwaps( t, newSuggestSwapsSetup(cfg, lnd, params), testCase.suggestions, nil, ) }) } } // TestSuggestSwaps tests getting of swap suggestions based on the rules set for // the liquidity manager and the current set of channel balances. func TestSuggestSwaps(t *testing.T) { singleChannel := []lndclient.ChannelInfo{ channel1, } expectedAmt := btcutil.Amount(10000) prepay, routing := testPPMFees(defaultFeePPM, testQuote, expectedAmt) tests := []struct { name string channels []lndclient.ChannelInfo rules map[lnwire.ShortChannelID]*SwapRule peerRules map[route.Vertex]*SwapRule suggestions *Suggestions err error }{ { name: "no rules", channels: singleChannel, rules: map[lnwire.ShortChannelID]*SwapRule{}, err: ErrNoRules, }, { name: "loop out", channels: singleChannel, rules: map[lnwire.ShortChannelID]*SwapRule{ chanID1: chanRule, }, suggestions: &Suggestions{ OutSwaps: []loop.OutRequest{ chan1Rec, }, DisqualifiedChans: noneDisqualified, DisqualifiedPeers: noPeersDisqualified, }, }, { name: "no rule for channel", channels: singleChannel, rules: map[lnwire.ShortChannelID]*SwapRule{ chanID2: { ThresholdRule: NewThresholdRule(10, 10), Type: swap.TypeOut, }, }, suggestions: &Suggestions{ DisqualifiedChans: noneDisqualified, DisqualifiedPeers: noPeersDisqualified, }, }, { name: "multiple peer rules", channels: []lndclient.ChannelInfo{ { PubKeyBytes: peer1, ChannelID: chanID1.ToUint64(), Capacity: 20000, LocalBalance: 8000, RemoteBalance: 12000, }, { PubKeyBytes: peer1, ChannelID: chanID2.ToUint64(), Capacity: 10000, LocalBalance: 9000, RemoteBalance: 1000, }, { PubKeyBytes: peer2, ChannelID: chanID3.ToUint64(), Capacity: 5000, LocalBalance: 2000, RemoteBalance: 3000, }, }, peerRules: map[route.Vertex]*SwapRule{ peer1: { ThresholdRule: NewThresholdRule(80, 0), Type: swap.TypeOut, }, peer2: { ThresholdRule: NewThresholdRule(40, 50), Type: swap.TypeOut, }, }, suggestions: &Suggestions{ OutSwaps: []loop.OutRequest{ { Amount: expectedAmt, OutgoingChanSet: loopdb.ChannelSet{ chanID1.ToUint64(), chanID2.ToUint64(), }, MaxPrepayRoutingFee: prepay, MaxSwapRoutingFee: routing, MaxMinerFee: scaleMaxMinerFee( scaleMinerFee(testQuote.MinerFee), ), MaxSwapFee: testQuote.SwapFee, MaxPrepayAmount: testQuote.PrepayAmount, SweepConfTarget: defaultConfTarget, Initiator: autoloopSwapInitiator, }, }, DisqualifiedChans: noneDisqualified, DisqualifiedPeers: map[route.Vertex]Reason{ peer2: ReasonLiquidityOk, }, }, }, } for _, testCase := range tests { testCase := testCase t.Run(testCase.name, func(t *testing.T) { cfg, lnd := newTestConfig() lnd.Channels = testCase.channels params := defaultParameters params.AutoloopBudgetLastRefresh = testBudgetStart if testCase.rules != nil { params.ChannelRules = testCase.rules } if testCase.peerRules != nil { params.PeerRules = testCase.peerRules } testSuggestSwaps( t, newSuggestSwapsSetup(cfg, lnd, params), testCase.suggestions, testCase.err, ) }) } } // TestFeeLimits tests limiting of swap suggestions by fees. func TestFeeLimits(t *testing.T) { quote := &loop.LoopOutQuote{ SwapFee: btcutil.Amount(1), PrepayAmount: btcutil.Amount(500), MinerFee: btcutil.Amount(50), } tests := []struct { name string quote *loop.LoopOutQuote suggestions *Suggestions }{ { name: "fees ok", quote: quote, suggestions: &Suggestions{ OutSwaps: []loop.OutRequest{ applyFeeCategoryQuote( chan1Rec, defaultMaximumMinerFee, defaultPrepayRoutingFeePPM, defaultRoutingFeePPM, *quote, ), }, DisqualifiedChans: noneDisqualified, DisqualifiedPeers: noPeersDisqualified, }, }, { name: "insufficient prepay", quote: &loop.LoopOutQuote{ SwapFee: 1, PrepayAmount: defaultMaximumPrepay + 1, MinerFee: 50, }, suggestions: &Suggestions{ DisqualifiedChans: map[lnwire.ShortChannelID]Reason{ chanID1: ReasonPrepay, }, DisqualifiedPeers: noPeersDisqualified, }, }, { name: "insufficient miner fee", quote: &loop.LoopOutQuote{ SwapFee: 1, PrepayAmount: 100, MinerFee: defaultMaximumMinerFee + 1, }, suggestions: &Suggestions{ DisqualifiedChans: map[lnwire.ShortChannelID]Reason{ chanID1: ReasonMinerFee, }, DisqualifiedPeers: noPeersDisqualified, }, }, { // Swap fee limited to 0.5% of 7500 = 37,5. name: "insufficient swap fee", quote: &loop.LoopOutQuote{ SwapFee: 38, PrepayAmount: 100, MinerFee: 500, }, suggestions: &Suggestions{ DisqualifiedChans: map[lnwire.ShortChannelID]Reason{ chanID1: ReasonSwapFee, }, DisqualifiedPeers: noPeersDisqualified, }, }, } 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 } lnd.Channels = []lndclient.ChannelInfo{ channel1, } // Set our params to use individual fee limits. params := defaultParameters params.AutoloopBudgetLastRefresh = testBudgetStart params.FeeLimit = defaultFeeCategoryLimit() // Set our budget to cover a single swap with these // parameters. params.AutoFeeBudget = defaultMaximumMinerFee + ppmToSat(7500, defaultSwapFeePPM) + ppmToSat(7500, defaultPrepayRoutingFeePPM) + ppmToSat(7500, defaultRoutingFeePPM) params.ChannelRules = map[lnwire.ShortChannelID]*SwapRule{ chanID1: chanRule, } testSuggestSwaps( t, newSuggestSwapsSetup(cfg, lnd, params), testCase.suggestions, nil, ) }) } } // TestFeeBudget tests limiting of swap suggestions to a fee budget, with and // without existing swaps. This test uses example channels and rules which need // a 7500 sat loop out. With our default parameters, and our test quote with // a prepay of 500, our total fees are (rounded due to int multiplication): // swap fee: 1 (as set in test quote) // route fee: 7500 * 0.005 = 37 // prepay route: 500 * 0.005 = 2 sat // max miner: set by default params // Since our routing fees are calculated as a portion of our swap/prepay // amounts, we use our max miner fee to shift swap cost to values above/below // our budget, fixing our other fees at 114 sat for simplicity. func TestFeeBudget(t *testing.T) { quote := &loop.LoopOutQuote{ SwapFee: btcutil.Amount(1), PrepayAmount: btcutil.Amount(500), MinerFee: btcutil.Amount(50), } chan1 := applyFeeCategoryQuote( chan1Rec, 5000, defaultPrepayRoutingFeePPM, defaultRoutingFeePPM, *quote, ) chan2 := applyFeeCategoryQuote( chan2Rec, 5000, defaultPrepayRoutingFeePPM, defaultRoutingFeePPM, *quote, ) tests := []struct { name string // budget is our autoloop budget. budget btcutil.Amount // maxMinerFee is the maximum miner fee we will pay for swaps. maxMinerFee btcutil.Amount // existingSwaps represents our existing swaps, mapping their // last update time to their total cost. existingSwaps map[time.Time]btcutil.Amount // suggestions is the set of swaps we expect to be suggested. suggestions *Suggestions }{ { // Two swaps will cost (78+5000)*2, set exactly 10156 // budget. name: "budget for 2 swaps, no existing", budget: 10156, maxMinerFee: 5000, suggestions: &Suggestions{ OutSwaps: []loop.OutRequest{ chan1, chan2, }, DisqualifiedChans: noneDisqualified, DisqualifiedPeers: noPeersDisqualified, }, }, { // Two swaps will cost (78+5000)*2, set 10155 so we can // only afford one swap. name: "budget for 1 swaps, no existing", budget: 10155, maxMinerFee: 5000, suggestions: &Suggestions{ OutSwaps: []loop.OutRequest{ chan1, }, DisqualifiedChans: map[lnwire.ShortChannelID]Reason{ chanID2: ReasonBudgetInsufficient, }, DisqualifiedPeers: noPeersDisqualified, }, }, { // Set an existing swap which would limit us to a single // swap if it were in our period. name: "existing swaps, before budget period", budget: 10156, maxMinerFee: 5000, existingSwaps: map[time.Time]btcutil.Amount{ testBudgetStart.Add(time.Hour * -1): 200, }, suggestions: &Suggestions{ OutSwaps: []loop.OutRequest{ chan1, chan2, }, DisqualifiedChans: noneDisqualified, DisqualifiedPeers: noPeersDisqualified, }, }, { // Add an existing swap in our budget period such that // we only have budget left for one more swap. name: "existing swaps, in budget period", budget: 10156, maxMinerFee: 5000, existingSwaps: map[time.Time]btcutil.Amount{ testBudgetStart.Add(time.Hour): 500, }, suggestions: &Suggestions{ OutSwaps: []loop.OutRequest{ chan1, }, DisqualifiedChans: map[lnwire.ShortChannelID]Reason{ chanID2: ReasonBudgetInsufficient, }, DisqualifiedPeers: noPeersDisqualified, }, }, { name: "existing swaps, budget used", budget: 500, maxMinerFee: 1000, existingSwaps: map[time.Time]btcutil.Amount{ testBudgetStart.Add(time.Hour): 500, }, suggestions: &Suggestions{ DisqualifiedChans: map[lnwire.ShortChannelID]Reason{ chanID1: ReasonBudgetElapsed, chanID2: ReasonBudgetElapsed, }, DisqualifiedPeers: noPeersDisqualified, }, }, } for _, testCase := range tests { testCase := testCase t.Run(testCase.name, func(t *testing.T) { cfg, lnd := newTestConfig() // Create a swap set of existing swaps with our set of // existing swap timestamps. swaps := make( []*loopdb.LoopOut, 0, len(testCase.existingSwaps), ) // Add an event with the timestamp and budget set by // our test case. for ts, amt := range testCase.existingSwaps { event := &loopdb.LoopEvent{ SwapStateData: loopdb.SwapStateData{ Cost: loopdb.SwapCost{ Server: amt, }, State: loopdb.StateSuccess, }, Time: ts, } swaps = append(swaps, &loopdb.LoopOut{ Loop: loopdb.Loop{ Events: []*loopdb.LoopEvent{ event, }, }, Contract: autoOutContract, }) } cfg.ListLoopOut = func(context.Context) ([]*loopdb.LoopOut, error) { return swaps, nil } cfg.LoopOutQuote = func(_ context.Context, _ *loop.LoopOutQuoteRequest) (*loop.LoopOutQuote, error) { return quote, nil } // Set two channels that need swaps. lnd.Channels = []lndclient.ChannelInfo{ channel1, channel2, } params := defaultParameters params.ChannelRules = map[lnwire.ShortChannelID]*SwapRule{ chanID1: chanRule, chanID2: chanRule, } params.AutoFeeBudget = testCase.budget params.AutoFeeRefreshPeriod = testBudgetRefresh params.AutoloopBudgetLastRefresh = testBudgetStart params.MaxAutoInFlight = 2 params.FeeLimit = NewFeeCategoryLimit( defaultSwapFeePPM, defaultRoutingFeePPM, defaultPrepayRoutingFeePPM, testCase.maxMinerFee, defaultMaximumPrepay, defaultSweepFeeRateLimit, ) // Set our custom max miner fee on each expected swap, // rather than having to create multiple vars for // different rates. for i := range testCase.suggestions.OutSwaps { testCase.suggestions.OutSwaps[i].MaxMinerFee = testCase.maxMinerFee } testSuggestSwaps( t, newSuggestSwapsSetup(cfg, lnd, params), testCase.suggestions, nil, ) }) } } // TestInFlightLimit tests the limit we place on the number of in-flight swaps // that are allowed. func TestInFlightLimit(t *testing.T) { tests := []struct { name string maxInFlight int existingSwaps []*loopdb.LoopOut existingInSwaps []*loopdb.LoopIn // peerRules will only be set (instead of test default values) // is it is non-nil. peerRules map[route.Vertex]*SwapRule suggestions *Suggestions }{ { name: "none in flight, extra space", maxInFlight: 3, suggestions: &Suggestions{ OutSwaps: []loop.OutRequest{ chan1Rec, chan2Rec, }, DisqualifiedChans: noneDisqualified, DisqualifiedPeers: noPeersDisqualified, }, }, { name: "none in flight, exact match", maxInFlight: 2, suggestions: &Suggestions{ OutSwaps: []loop.OutRequest{ chan1Rec, chan2Rec, }, DisqualifiedChans: noneDisqualified, DisqualifiedPeers: noPeersDisqualified, }, }, { name: "one in flight, one allowed", maxInFlight: 2, existingSwaps: []*loopdb.LoopOut{ { Contract: autoOutContract, }, }, suggestions: &Suggestions{ OutSwaps: []loop.OutRequest{ chan1Rec, }, DisqualifiedChans: map[lnwire.ShortChannelID]Reason{ chanID2: ReasonInFlight, }, DisqualifiedPeers: noPeersDisqualified, }, }, { name: "max in flight", maxInFlight: 1, existingSwaps: []*loopdb.LoopOut{ { Contract: autoOutContract, }, }, suggestions: &Suggestions{ DisqualifiedChans: map[lnwire.ShortChannelID]Reason{ chanID1: ReasonInFlight, chanID2: ReasonInFlight, }, DisqualifiedPeers: noPeersDisqualified, }, }, { name: "max swaps exceeded", maxInFlight: 1, existingSwaps: []*loopdb.LoopOut{ { Contract: autoOutContract, }, }, existingInSwaps: []*loopdb.LoopIn{ { Contract: autoInContract, }, }, suggestions: &Suggestions{ DisqualifiedChans: map[lnwire.ShortChannelID]Reason{ chanID1: ReasonInFlight, chanID2: ReasonInFlight, }, DisqualifiedPeers: noPeersDisqualified, }, }, { name: "peer rules max swaps exceeded", maxInFlight: 2, existingSwaps: []*loopdb.LoopOut{ { Contract: autoOutContract, }, }, // Create two peer-level rules, both in need of a swap, // but peer 1 needs a larger swap so will be // prioritized. peerRules: map[route.Vertex]*SwapRule{ peer1: { ThresholdRule: NewThresholdRule(50, 0), Type: swap.TypeOut, }, peer2: { ThresholdRule: NewThresholdRule(40, 0), Type: swap.TypeOut, }, }, suggestions: &Suggestions{ OutSwaps: []loop.OutRequest{ chan1Rec, }, DisqualifiedChans: noneDisqualified, DisqualifiedPeers: map[route.Vertex]Reason{ peer2: ReasonInFlight, }, }, }, } for _, testCase := range tests { testCase := testCase t.Run(testCase.name, func(t *testing.T) { cfg, lnd := newTestConfig() cfg.ListLoopOut = func(context.Context) ([]*loopdb.LoopOut, error) { return testCase.existingSwaps, nil } cfg.ListLoopIn = func(context.Context) ([]*loopdb.LoopIn, error) { return testCase.existingInSwaps, nil } lnd.Channels = []lndclient.ChannelInfo{ channel1, channel2, } params := defaultParameters params.AutoloopBudgetLastRefresh = testBudgetStart if testCase.peerRules != nil { params.PeerRules = testCase.peerRules } else { params.ChannelRules = map[lnwire.ShortChannelID]*SwapRule{ chanID1: chanRule, chanID2: chanRule, } } params.MaxAutoInFlight = testCase.maxInFlight // By default we only have budget for one swap, increase // our budget so that we could recommend more than one // swap at a time. params.AutoFeeBudget = defaultBudget * 2 testSuggestSwaps( t, newSuggestSwapsSetup(cfg, lnd, params), testCase.suggestions, nil, ) }) } } type mockServer struct { mock.Mock } // Restrictions mocks a call to the server to get swap size restrictions. func (m *mockServer) Restrictions(ctx context.Context, swapType swap.Type, initiator string) ( *Restrictions, error) { args := m.Called(ctx, swapType) return args.Get(0).(*Restrictions), args.Error(1) } // TestSizeRestrictions tests the use of client-set size restrictions on swaps. func TestSizeRestrictions(t *testing.T) { var ( serverRestrictions = Restrictions{ Minimum: 6000, Maximum: 10000, } prepay, routing = testPPMFees(defaultFeePPM, testQuote, 7000) outSwap = loop.OutRequest{ Amount: 7000, OutgoingChanSet: loopdb.ChannelSet{chanID1.ToUint64()}, MaxPrepayRoutingFee: prepay, MaxSwapRoutingFee: routing, MaxMinerFee: scaleMaxMinerFee( scaleMinerFee(testQuote.MinerFee), ), MaxSwapFee: testQuote.SwapFee, MaxPrepayAmount: testQuote.PrepayAmount, SweepConfTarget: defaultConfTarget, Initiator: autoloopSwapInitiator, } ) tests := []struct { name string // clientRestrictions holds the restrictions that the client // has configured. clientRestrictions Restrictions prepareMock func(m *mockServer) // suggestions is the set of suggestions we expect. suggestions *Suggestions // expectedError is the error we expect. expectedError error }{ { name: "minimum more than server, swap happens", clientRestrictions: Restrictions{ Minimum: 7000, }, suggestions: &Suggestions{ OutSwaps: []loop.OutRequest{ chan1Rec, }, DisqualifiedChans: noneDisqualified, DisqualifiedPeers: noPeersDisqualified, }, }, { name: "minimum more than server, no swap", clientRestrictions: Restrictions{ Minimum: 8000, }, suggestions: &Suggestions{ DisqualifiedChans: map[lnwire.ShortChannelID]Reason{ chanID1: ReasonLiquidityOk, }, DisqualifiedPeers: noPeersDisqualified, }, }, { name: "maximum less than server, swap happens", clientRestrictions: Restrictions{ Maximum: 7000, }, suggestions: &Suggestions{ OutSwaps: []loop.OutRequest{ outSwap, }, DisqualifiedChans: noneDisqualified, DisqualifiedPeers: noPeersDisqualified, }, }, { // Originally, our client params are ok. But then the // server increases its minimum, making the client // params stale. name: "client params stale over time", clientRestrictions: Restrictions{ Minimum: 6500, Maximum: 9000, }, prepareMock: func(m *mockServer) { restrictions := serverRestrictions m.On( "Restrictions", mock.Anything, swap.TypeOut, ).Return( &restrictions, nil, ).Once() m.On( "Restrictions", mock.Anything, swap.TypeOut, ).Return( &Restrictions{ Minimum: 5000, Maximum: 6000, }, nil, ).Once() }, suggestions: nil, expectedError: ErrMaxExceedsServer, }, } for _, testCase := range tests { testCase := testCase t.Run(testCase.name, func(t *testing.T) { cfg, lnd := newTestConfig() lnd.Channels = []lndclient.ChannelInfo{ channel1, } params := defaultParameters params.AutoloopBudgetLastRefresh = testBudgetStart params.ClientRestrictions = testCase.clientRestrictions params.ChannelRules = map[lnwire.ShortChannelID]*SwapRule{ chanID1: chanRule, } // Use a mock that has our expected calls for the test // case set to provide server restrictions. mockServer := &mockServer{} // If the test wants us to prime the mock, use its // function, otherwise just return our default // restrictions. if testCase.prepareMock != nil { testCase.prepareMock(mockServer) } else { restrictions := serverRestrictions mockServer.On( "Restrictions", mock.Anything, mock.Anything, ).Return(&restrictions, nil) } cfg.Restrictions = mockServer.Restrictions testSuggestSwaps( t, newSuggestSwapsSetup(cfg, lnd, params), testCase.suggestions, testCase.expectedError, ) mockServer.AssertExpectations(t) }) } } // TestFeePercentage tests use of a flat fee percentage to limit the fees we // pay for swaps. Our test is setup to require a 7500 sat swap, and we test // this amount against various fee percentages and server quotes. func TestFeePercentage(t *testing.T) { var ( okPPM uint64 = 30000 okQuote = &loop.LoopOutQuote{ SwapFee: 15, PrepayAmount: 30, MinerFee: 1, } rec = loop.OutRequest{ Amount: 7500, OutgoingChanSet: loopdb.ChannelSet{chanID1.ToUint64()}, MaxMinerFee: scaleMaxMinerFee( scaleMinerFee(testQuote.MinerFee), ), MaxSwapFee: okQuote.SwapFee, MaxPrepayAmount: okQuote.PrepayAmount, SweepConfTarget: defaultConfTarget, Initiator: autoloopSwapInitiator, } ) rec.MaxPrepayRoutingFee, rec.MaxSwapRoutingFee = testPPMFees( okPPM, okQuote, 7500, ) tests := []struct { name string feePPM uint64 quote *loop.LoopOutQuote suggestions *Suggestions }{ { // With our limit set to 3% of swap amount 7500, we // have a total budget of 225 sat. name: "fees ok", feePPM: okPPM, quote: okQuote, suggestions: &Suggestions{ OutSwaps: []loop.OutRequest{ rec, }, DisqualifiedChans: noneDisqualified, DisqualifiedPeers: noPeersDisqualified, }, }, { name: "swap fee too high", feePPM: 20000, quote: &loop.LoopOutQuote{ SwapFee: 300, PrepayAmount: 30, MinerFee: 1, }, suggestions: &Suggestions{ DisqualifiedChans: map[lnwire.ShortChannelID]Reason{ chanID1: ReasonSwapFee, }, DisqualifiedPeers: noPeersDisqualified, }, }, { name: "miner fee too high", feePPM: 20000, quote: &loop.LoopOutQuote{ SwapFee: 80, PrepayAmount: 30, MinerFee: 300, }, suggestions: &Suggestions{ DisqualifiedChans: map[lnwire.ShortChannelID]Reason{ chanID1: ReasonMinerFee, }, DisqualifiedPeers: noPeersDisqualified, }, }, { name: "miner and swap too high", feePPM: 20000, quote: &loop.LoopOutQuote{ SwapFee: 60, PrepayAmount: 30, MinerFee: 50, }, suggestions: &Suggestions{ DisqualifiedChans: map[lnwire.ShortChannelID]Reason{ chanID1: ReasonFeePPMInsufficient, }, DisqualifiedPeers: noPeersDisqualified, }, }, } 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 } lnd.Channels = []lndclient.ChannelInfo{ channel1, } params := defaultParameters params.AutoloopBudgetLastRefresh = testBudgetStart params.FeeLimit = NewFeePortion(testCase.feePPM) params.ChannelRules = map[lnwire.ShortChannelID]*SwapRule{ chanID1: chanRule, } testSuggestSwaps( t, newSuggestSwapsSetup(cfg, lnd, params), testCase.suggestions, nil, ) }) } } // TestBudgetWithLoopin tests that our autoloop budget accounts for loop in // swaps that have been automatically dispatched. It tests out swaps that have // already completed and those that are pending, inside and outside of our // budget period to ensure that we account for all relevant swaps. func TestBudgetWithLoopin(t *testing.T) { var ( budget btcutil.Amount = 10000 outsideBudget = testBudgetStart.Add(-5) insideBudget = testBudgetStart.Add(5) contractOutsideBudget = &loopdb.LoopInContract{ SwapContract: loopdb.SwapContract{ InitiationTime: outsideBudget, MaxSwapFee: budget, Label: labels.AutoloopLabel( swap.TypeIn, ), }, } // Set our spend equal to our budget so we don't need to // calculate exact costs. eventOutsideBudget = &loopdb.LoopEvent{ SwapStateData: loopdb.SwapStateData{ Cost: loopdb.SwapCost{ Server: budget, }, State: loopdb.StateSuccess, }, Time: outsideBudget, } successWithinBudget = &loopdb.LoopEvent{ SwapStateData: loopdb.SwapStateData{ Cost: loopdb.SwapCost{ Server: budget, }, State: loopdb.StateSuccess, }, Time: insideBudget, } okQuote = &loop.LoopOutQuote{ SwapFee: 15, PrepayAmount: 30, MinerFee: 1, } rec = loop.OutRequest{ Amount: 7500, OutgoingChanSet: loopdb.ChannelSet{chanID1.ToUint64()}, MaxMinerFee: scaleMaxMinerFee( scaleMinerFee(testQuote.MinerFee), ), MaxSwapFee: okQuote.SwapFee, MaxPrepayAmount: okQuote.PrepayAmount, SweepConfTarget: defaultConfTarget, Initiator: autoloopSwapInitiator, } testPPM uint64 = 100000 ) rec.MaxPrepayRoutingFee, rec.MaxSwapRoutingFee = testPPMFees( testPPM, okQuote, 7500, ) tests := []struct { name string // loopIns is the set of loop in swaps that the client has // performed. loopIns []*loopdb.LoopIn // suggestions is the set of swaps that we expect to be // suggested given our current traffic. suggestions *Suggestions }{ { name: "completed swap outside of budget", loopIns: []*loopdb.LoopIn{ { Loop: loopdb.Loop{ Events: []*loopdb.LoopEvent{ eventOutsideBudget, }, }, Contract: contractOutsideBudget, }, }, suggestions: &Suggestions{ OutSwaps: []loop.OutRequest{ rec, }, DisqualifiedChans: noneDisqualified, DisqualifiedPeers: noPeersDisqualified, }, }, { name: "completed within budget", loopIns: []*loopdb.LoopIn{ { Loop: loopdb.Loop{ Events: []*loopdb.LoopEvent{ successWithinBudget, }, }, Contract: contractOutsideBudget, }, }, suggestions: &Suggestions{ DisqualifiedChans: map[lnwire.ShortChannelID]Reason{ chanID1: ReasonBudgetElapsed, }, DisqualifiedPeers: noPeersDisqualified, }, }, { name: "pending created before budget", loopIns: []*loopdb.LoopIn{ { Contract: contractOutsideBudget, }, }, suggestions: &Suggestions{ DisqualifiedChans: map[lnwire.ShortChannelID]Reason{ chanID1: ReasonBudgetElapsed, }, DisqualifiedPeers: noPeersDisqualified, }, }, } for _, testCase := range tests { testCase := testCase t.Run(testCase.name, func(t *testing.T) { cfg, lnd := newTestConfig() // Set our channel and rules so that we will need to // swap 7500 sats and our fee limit is 10% of that // amount (750 sats). lnd.Channels = []lndclient.ChannelInfo{ channel1, } cfg.ListLoopIn = func(context.Context) ([]*loopdb.LoopIn, error) { return testCase.loopIns, nil } cfg.LoopOutQuote = func(_ context.Context, _ *loop.LoopOutQuoteRequest) (*loop.LoopOutQuote, error) { return okQuote, nil } params := defaultParameters params.AutoFeeBudget = budget params.AutoFeeRefreshPeriod = testBudgetRefresh params.AutoloopBudgetLastRefresh = testBudgetStart params.FeeLimit = NewFeePortion(testPPM) params.ChannelRules = map[lnwire.ShortChannelID]*SwapRule{ chanID1: chanRule, } // Allow more than one in flight swap, to ensure that // we restrict based on budget, not in-flight. params.MaxAutoInFlight = 2 testSuggestSwaps( t, newSuggestSwapsSetup(cfg, lnd, params), testCase.suggestions, nil, ) }) } } // testSuggestSwapsSetup contains the elements that are used to create a // suggest swaps test. type testSuggestSwapsSetup struct { cfg *Config lnd *test.LndMockServices params Parameters } // newSuggestSwapsSetup creates a suggest swaps setup struct. func newSuggestSwapsSetup(cfg *Config, lnd *test.LndMockServices, params Parameters) *testSuggestSwapsSetup { return &testSuggestSwapsSetup{ cfg: cfg, lnd: lnd, params: params, } } // testSuggestSwaps tests getting swap suggestions. It takes a setup struct // which contains custom setup for the test. If this struct is nil, it will // use the default parameters and setup two channels (channel1 + channel2) with // chanRule set for each. func testSuggestSwaps(t *testing.T, setup *testSuggestSwapsSetup, expected *Suggestions, expectedErr error) { t.Parallel() // If our setup struct is nil, we replace it with our default test // values. if setup == nil { cfg, lnd := newTestConfig() lnd.Channels = []lndclient.ChannelInfo{ channel1, channel2, } params := defaultParameters params.AutoloopBudgetLastRefresh = testBudgetStart params.ChannelRules = map[lnwire.ShortChannelID]*SwapRule{ chanID1: chanRule, chanID2: chanRule, } setup = &testSuggestSwapsSetup{ cfg: cfg, lnd: lnd, params: params, } } // Create a new manager, get our current set of parameters and update // them to use the rules set by the test. manager := NewManager(setup.cfg) err := manager.setParameters(context.Background(), setup.params) require.NoError(t, err) actual, err := manager.SuggestSwaps(context.Background()) require.Equal(t, expectedErr, err) require.Equal(t, expected, actual) } // TestCurrentTraffic tests recording of our current set of ongoing swaps. func TestCurrentTraffic(t *testing.T) { var ( backoff = time.Hour * 5 withinBackoff = testTime.Add(time.Hour * -1) outsideBackoff = testTime.Add(backoff * -2) success = []*loopdb.LoopEvent{ { SwapStateData: loopdb.SwapStateData{ State: loopdb.StateSuccess, }, }, } failedInBackoff = []*loopdb.LoopEvent{ { SwapStateData: loopdb.SwapStateData{ State: loopdb.StateFailOffchainPayments, }, Time: withinBackoff, }, } failedOutsideBackoff = []*loopdb.LoopEvent{ { SwapStateData: loopdb.SwapStateData{ State: loopdb.StateFailOffchainPayments, }, Time: outsideBackoff, }, } failedTimeoutInBackoff = []*loopdb.LoopEvent{ { SwapStateData: loopdb.SwapStateData{ State: loopdb.StateFailTimeout, }, Time: withinBackoff, }, } failedTimeoutOutsideBackoff = []*loopdb.LoopEvent{ { SwapStateData: loopdb.SwapStateData{ State: loopdb.StateFailTimeout, }, Time: outsideBackoff, }, } ) tests := []struct { name string loopOut []*loopdb.LoopOut loopIn []*loopdb.LoopIn expected *swapTraffic }{ { name: "completed swaps ignored", loopOut: []*loopdb.LoopOut{ { Loop: loopdb.Loop{ Events: success, }, Contract: &loopdb.LoopOutContract{}, }, }, loopIn: []*loopdb.LoopIn{ { Loop: loopdb.Loop{ Events: success, }, Contract: &loopdb.LoopInContract{}, }, }, expected: newSwapTraffic(), }, { // No events indicates that the swap is still pending. name: "pending swaps included", loopOut: []*loopdb.LoopOut{ { Contract: &loopdb.LoopOutContract{ OutgoingChanSet: []uint64{ chanID1.ToUint64(), }, }, }, }, loopIn: []*loopdb.LoopIn{ { Contract: &loopdb.LoopInContract{ LastHop: &peer2, }, }, }, expected: &swapTraffic{ ongoingLoopOut: map[lnwire.ShortChannelID]bool{ chanID1: true, }, ongoingLoopIn: map[route.Vertex]bool{ peer2: true, }, // Make empty maps so that we can assert equal. failedLoopOut: make( map[lnwire.ShortChannelID]time.Time, ), failedLoopIn: make(map[route.Vertex]time.Time), }, }, { name: "failure backoff included", loopOut: []*loopdb.LoopOut{ { Contract: &loopdb.LoopOutContract{ OutgoingChanSet: []uint64{ chanID1.ToUint64(), }, }, Loop: loopdb.Loop{ Events: failedInBackoff, }, }, { Contract: &loopdb.LoopOutContract{ OutgoingChanSet: []uint64{ chanID2.ToUint64(), }, }, Loop: loopdb.Loop{ Events: failedOutsideBackoff, }, }, }, loopIn: []*loopdb.LoopIn{ { Contract: &loopdb.LoopInContract{ LastHop: &peer1, }, Loop: loopdb.Loop{ Events: failedTimeoutInBackoff, }, }, { Contract: &loopdb.LoopInContract{ LastHop: &peer2, }, Loop: loopdb.Loop{ Events: failedTimeoutOutsideBackoff, }, }, }, expected: &swapTraffic{ ongoingLoopOut: make( map[lnwire.ShortChannelID]bool, ), ongoingLoopIn: make(map[route.Vertex]bool), failedLoopOut: map[lnwire.ShortChannelID]time.Time{ chanID1: withinBackoff, }, failedLoopIn: map[route.Vertex]time.Time{ peer1: withinBackoff, }, }, }, } for _, testCase := range tests { cfg, _ := newTestConfig() m := NewManager(cfg) params := m.GetParameters() params.FailureBackOff = backoff require.NoError(t, m.setParameters(context.Background(), params)) actual := m.currentSwapTraffic(testCase.loopOut, testCase.loopIn) require.Equal(t, testCase.expected, actual) } }