You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
loop/liquidity/autoloop_test.go

1512 lines
38 KiB
Go

package liquidity
import (
"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"
"github.com/lightninglabs/loop/swap"
"github.com/lightninglabs/loop/test"
"github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/lnwire"
"github.com/lightningnetwork/lnd/routing/route"
"github.com/stretchr/testify/require"
)
// 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.AutoloopBudgetLastRefresh = testBudgetStart
params.ChannelRules = map[lnwire.ShortChannelID]*SwapRule{
chanID1: chanRule,
}
c := newAutoloopTestCtx(t, params, channels, testRestrictions)
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.
step := &autoloopStep{
minAmt: 1,
maxAmt: chan1Rec.Amount + 1,
quotesOut: quotes,
}
c.autoloop(step)
// 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.
step = &autoloopStep{
minAmt: chan1Rec.Amount + 1,
maxAmt: chan1Rec.Amount + 2,
}
c.autoloop(step)
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)()
var (
channels = []lndclient.ChannelInfo{
channel1, channel2,
}
swapFeePPM uint64 = 1000
routeFeePPM uint64 = 1000
prepayFeePPM uint64 = 1000
prepayAmount = btcutil.Amount(20000)
maxMiner = btcutil.Amount(20000)
// 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{
Autoloop: true,
AutoFeeBudget: 40066,
AutoFeeRefreshPeriod: testBudgetRefresh,
AutoloopBudgetLastRefresh: testBudgetStart,
MaxAutoInFlight: 2,
FailureBackOff: time.Hour,
SweepConfTarget: 10,
FeeLimit: NewFeeCategoryLimit(
swapFeePPM, routeFeePPM, prepayFeePPM, maxMiner,
prepayAmount, 20000,
),
ChannelRules: map[lnwire.ShortChannelID]*SwapRule{
chanID1: chanRule,
chanID2: chanRule,
},
HtlcConfTarget: defaultHtlcConfTarget,
}
)
c := newAutoloopTestCtx(t, params, channels, testRestrictions)
c.start()
// Calculate our maximum allowed fees and create quotes that fall within
// our budget.
var (
amt = chan1Rec.Amount
maxSwapFee = ppmToSat(amt, swapFeePPM)
// 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: prepayAmount - 10,
MinerFee: maxMiner - 10,
}
quote2 = &loop.LoopOutQuote{
SwapFee: maxSwapFee,
PrepayAmount: prepayAmount - 20,
MinerFee: maxMiner - 10,
}
quoteRequest = &loop.LoopOutQuoteRequest{
Amount: amt,
SweepConfTarget: params.SweepConfTarget,
}
quotes = []quoteRequestResp{
{
request: quoteRequest,
quote: quote1,
},
{
request: quoteRequest,
quote: quote2,
},
}
maxRouteFee = ppmToSat(amt, routeFeePPM)
chan1Swap = &loop.OutRequest{
Amount: amt,
MaxSwapRoutingFee: maxRouteFee,
MaxPrepayRoutingFee: ppmToSat(
quote1.PrepayAmount, prepayFeePPM,
),
MaxSwapFee: quote1.SwapFee,
MaxPrepayAmount: quote1.PrepayAmount,
MaxMinerFee: maxMiner,
SweepConfTarget: params.SweepConfTarget,
OutgoingChanSet: loopdb.ChannelSet{chanID1.ToUint64()},
Label: labels.AutoloopLabel(swap.TypeOut),
Initiator: autoloopSwapInitiator,
}
chan2Swap = &loop.OutRequest{
Amount: amt,
MaxSwapRoutingFee: maxRouteFee,
MaxPrepayRoutingFee: ppmToSat(
quote2.PrepayAmount, routeFeePPM,
),
MaxSwapFee: quote2.SwapFee,
MaxPrepayAmount: quote2.PrepayAmount,
MaxMinerFee: maxMiner,
SweepConfTarget: params.SweepConfTarget,
OutgoingChanSet: loopdb.ChannelSet{chanID2.ToUint64()},
Label: labels.AutoloopLabel(swap.TypeOut),
Initiator: autoloopSwapInitiator,
}
loopOuts = []loopOutRequestResp{
{
request: chan1Swap,
response: &loop.LoopOutSwapInfo{
SwapHash: lntypes.Hash{1},
},
},
{
request: chan2Swap,
response: &loop.LoopOutSwapInfo{
SwapHash: lntypes.Hash{2},
},
},
}
singleLoopOut = &loopdb.LoopOut{
Loop: loopdb.Loop{
Events: []*loopdb.LoopEvent{
{
SwapStateData: loopdb.SwapStateData{
State: loopdb.StateSuccess,
},
},
},
},
}
)
// Tick our autolooper with no existing swaps, we expect a loop out
// swap to be dispatched for each channel.
step := &autoloopStep{
minAmt: 1,
maxAmt: amt + 1,
quotesOut: quotes,
expectedOut: loopOuts,
existingOutSingle: singleLoopOut,
}
c.autoloop(step)
// 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),
}
step = &autoloopStep{
minAmt: 1,
maxAmt: amt + 1,
existingOut: existing,
existingOutSingle: singleLoopOut,
}
c.autoloop(step)
// 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: maxMiner,
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.
step = &autoloopStep{
minAmt: 1,
maxAmt: amt + 1,
existingOut: existing,
quotesOut: quotes,
expectedOut: loopOuts,
existingOutSingle: singleLoopOut,
}
c.autoloop(step)
// 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),
}
step = &autoloopStep{
minAmt: 1,
maxAmt: amt + 1,
existingOut: existing,
quotesOut: quotes,
existingOutSingle: singleLoopOut,
}
c.autoloop(step)
c.stop()
}
// TestAutoloopAddress tests that the custom destination address feature for
// loop out behaves as expected.
func TestAutoloopAddress(t *testing.T) {
defer test.Guard(t)()
// Decode a dummy p2wkh address to use as the destination address for
// the swaps.
p2wkhAddr := "bcrt1qq68r6ff4k4pjx39efs44gcyccf7unqnu5qtjjz"
addr, err := btcutil.DecodeAddress(p2wkhAddr, nil)
if err != nil {
t.Error(err)
}
var (
channels = []lndclient.ChannelInfo{
channel1, channel2,
}
swapFeePPM uint64 = 1000
routeFeePPM uint64 = 1000
prepayFeePPM uint64 = 1000
prepayAmount = btcutil.Amount(20000)
maxMiner = btcutil.Amount(20000)
// Create some dummy parameters for autoloop and also specify an
// destination address.
params = Parameters{
Autoloop: true,
AutoFeeBudget: 40066,
DestAddr: addr,
AutoFeeRefreshPeriod: testBudgetRefresh,
AutoloopBudgetLastRefresh: testBudgetStart,
MaxAutoInFlight: 2,
FailureBackOff: time.Hour,
SweepConfTarget: 10,
FeeLimit: NewFeeCategoryLimit(
swapFeePPM, routeFeePPM, prepayFeePPM, maxMiner,
prepayAmount, 20000,
),
ChannelRules: map[lnwire.ShortChannelID]*SwapRule{
chanID1: chanRule,
chanID2: chanRule,
},
HtlcConfTarget: defaultHtlcConfTarget,
}
)
c := newAutoloopTestCtx(t, params, channels, testRestrictions)
c.start()
// Get parameters from manager and verify that address is set correctly.
params = c.manager.GetParameters()
require.Equal(t, params.DestAddr, addr)
// Calculate our maximum allowed fees and create quotes that fall within
// our budget.
var (
amt = chan1Rec.Amount
maxSwapFee = ppmToSat(amt, swapFeePPM)
quote1 = &loop.LoopOutQuote{
SwapFee: maxSwapFee,
PrepayAmount: prepayAmount - 10,
MinerFee: maxMiner - 10,
}
quote2 = &loop.LoopOutQuote{
SwapFee: maxSwapFee,
PrepayAmount: prepayAmount - 20,
MinerFee: maxMiner - 10,
}
quoteRequest = &loop.LoopOutQuoteRequest{
Amount: amt,
SweepConfTarget: params.SweepConfTarget,
}
quotes = []quoteRequestResp{
{
request: quoteRequest,
quote: quote1,
},
{
request: quoteRequest,
quote: quote2,
},
}
maxRouteFee = ppmToSat(amt, routeFeePPM)
chan1Swap = &loop.OutRequest{
Amount: amt,
// Define the expected destination address.
DestAddr: addr,
IsExternalAddr: true,
MaxSwapRoutingFee: maxRouteFee,
MaxPrepayRoutingFee: ppmToSat(
quote1.PrepayAmount, prepayFeePPM,
),
MaxSwapFee: quote1.SwapFee,
MaxPrepayAmount: quote1.PrepayAmount,
MaxMinerFee: maxMiner,
SweepConfTarget: params.SweepConfTarget,
OutgoingChanSet: loopdb.ChannelSet{chanID1.ToUint64()},
Label: labels.AutoloopLabel(swap.TypeOut),
Initiator: autoloopSwapInitiator,
}
chan2Swap = &loop.OutRequest{
Amount: amt,
// Define the expected destination address.
DestAddr: addr,
IsExternalAddr: true,
MaxSwapRoutingFee: maxRouteFee,
MaxPrepayRoutingFee: ppmToSat(
quote2.PrepayAmount, routeFeePPM,
),
MaxSwapFee: quote2.SwapFee,
MaxPrepayAmount: quote2.PrepayAmount,
MaxMinerFee: maxMiner,
SweepConfTarget: params.SweepConfTarget,
OutgoingChanSet: loopdb.ChannelSet{chanID2.ToUint64()},
Label: labels.AutoloopLabel(swap.TypeOut),
Initiator: autoloopSwapInitiator,
}
loopOuts = []loopOutRequestResp{
{
request: chan1Swap,
response: &loop.LoopOutSwapInfo{
SwapHash: lntypes.Hash{1},
},
},
{
request: chan2Swap,
response: &loop.LoopOutSwapInfo{
SwapHash: lntypes.Hash{2},
},
},
}
singleLoopOut = &loopdb.LoopOut{
Loop: loopdb.Loop{
Events: []*loopdb.LoopEvent{
{
SwapStateData: loopdb.SwapStateData{
State: loopdb.StateSuccess,
},
},
},
},
}
)
step := &autoloopStep{
minAmt: 1,
maxAmt: amt + 1,
quotesOut: quotes,
expectedOut: loopOuts,
existingOutSingle: singleLoopOut,
keepDestAddr: true,
}
c.autoloop(step)
c.stop()
}
// TestCompositeRules tests the case where we have rules set on a per peer
// and per channel basis, and perform swaps for both targets.
func TestCompositeRules(t *testing.T) {
defer test.Guard(t)()
var (
// Setup our channels so that we have two channels with peer 2,
// and a single channel with peer 1.
channel3 = lndclient.ChannelInfo{
ChannelID: chanID3.ToUint64(),
PubKeyBytes: peer2,
LocalBalance: 10000,
RemoteBalance: 0,
Capacity: 10000,
}
channels = []lndclient.ChannelInfo{
channel1, channel2, channel3,
}
swapFeePPM uint64 = 1000
routeFeePPM uint64 = 1000
prepayFeePPM uint64 = 1000
prepayAmount = btcutil.Amount(20000)
maxMiner = btcutil.Amount(20000)
// Create a set of parameters with autoloop enabled, set our
// budget to a value that will easily accommodate our two swaps.
params = Parameters{
FeeLimit: NewFeeCategoryLimit(
swapFeePPM, routeFeePPM, prepayFeePPM, maxMiner,
prepayAmount, 20000,
),
Autoloop: true,
AutoFeeBudget: 100000,
AutoFeeRefreshPeriod: testBudgetRefresh,
AutoloopBudgetLastRefresh: testBudgetStart,
MaxAutoInFlight: 2,
FailureBackOff: time.Hour,
SweepConfTarget: 10,
ChannelRules: map[lnwire.ShortChannelID]*SwapRule{
chanID1: chanRule,
},
PeerRules: map[route.Vertex]*SwapRule{
peer2: chanRule,
},
HtlcConfTarget: defaultHtlcConfTarget,
}
)
c := newAutoloopTestCtx(t, params, channels, testRestrictions)
c.start()
// Calculate our maximum allowed fees and create quotes that fall within
// our budget.
var (
// Create a quote for our peer level swap that is within
// our budget, with an amount which would balance the peer
/// across all of its channels.
peerAmount = btcutil.Amount(15000)
maxPeerSwapFee = ppmToSat(peerAmount, swapFeePPM)
peerSwapQuote = &loop.LoopOutQuote{
SwapFee: maxPeerSwapFee,
PrepayAmount: prepayAmount - 20,
MinerFee: maxMiner - 10,
}
peerSwapQuoteRequest = &loop.LoopOutQuoteRequest{
Amount: peerAmount,
SweepConfTarget: params.SweepConfTarget,
}
maxPeerRouteFee = ppmToSat(peerAmount, routeFeePPM)
peerSwap = &loop.OutRequest{
Amount: peerAmount,
MaxSwapRoutingFee: maxPeerRouteFee,
MaxPrepayRoutingFee: ppmToSat(
peerSwapQuote.PrepayAmount, routeFeePPM,
),
MaxSwapFee: peerSwapQuote.SwapFee,
MaxPrepayAmount: peerSwapQuote.PrepayAmount,
MaxMinerFee: maxMiner,
SweepConfTarget: params.SweepConfTarget,
OutgoingChanSet: loopdb.ChannelSet{
chanID2.ToUint64(), chanID3.ToUint64(),
},
Label: labels.AutoloopLabel(swap.TypeOut),
Initiator: autoloopSwapInitiator,
}
// Create a quote for our single channel swap that is within
// our budget.
chanAmount = chan1Rec.Amount
maxChanSwapFee = ppmToSat(chanAmount, swapFeePPM)
channelSwapQuote = &loop.LoopOutQuote{
SwapFee: maxChanSwapFee,
PrepayAmount: prepayAmount - 10,
MinerFee: maxMiner - 10,
}
chanSwapQuoteRequest = &loop.LoopOutQuoteRequest{
Amount: chanAmount,
SweepConfTarget: params.SweepConfTarget,
}
maxChanRouteFee = ppmToSat(chanAmount, routeFeePPM)
chanSwap = &loop.OutRequest{
Amount: chanAmount,
MaxSwapRoutingFee: maxChanRouteFee,
MaxPrepayRoutingFee: ppmToSat(
channelSwapQuote.PrepayAmount, routeFeePPM,
),
MaxSwapFee: channelSwapQuote.SwapFee,
MaxPrepayAmount: channelSwapQuote.PrepayAmount,
MaxMinerFee: maxMiner,
SweepConfTarget: params.SweepConfTarget,
OutgoingChanSet: loopdb.ChannelSet{chanID1.ToUint64()},
Label: labels.AutoloopLabel(swap.TypeOut),
Initiator: autoloopSwapInitiator,
}
quotes = []quoteRequestResp{
{
request: peerSwapQuoteRequest,
quote: peerSwapQuote,
},
{
request: chanSwapQuoteRequest,
quote: channelSwapQuote,
},
}
loopOuts = []loopOutRequestResp{
{
request: peerSwap,
response: &loop.LoopOutSwapInfo{
SwapHash: lntypes.Hash{2},
},
},
{
request: chanSwap,
response: &loop.LoopOutSwapInfo{
SwapHash: lntypes.Hash{1},
},
},
}
singleLoopOut = &loopdb.LoopOut{
Loop: loopdb.Loop{
Events: []*loopdb.LoopEvent{
{
SwapStateData: loopdb.SwapStateData{
State: loopdb.StateSuccess,
},
},
},
},
}
)
// Tick our autolooper with no existing swaps, we expect a loop out
// swap to be dispatched for each of our rules. We set our server side
// maximum to be greater than the swap amount for our peer swap (which
// is the larger of the two swaps).
step := &autoloopStep{
minAmt: 1,
maxAmt: peerAmount + 1,
quotesOut: quotes,
expectedOut: loopOuts,
existingOutSingle: singleLoopOut,
}
c.autoloop(step)
c.stop()
}
// TestAutoLoopInEnabled tests dispatch of autoloop in swaps.
func TestAutoLoopInEnabled(t *testing.T) {
defer test.Guard(t)()
var (
chan1 = lndclient.ChannelInfo{
ChannelID: chanID1.ToUint64(),
PubKeyBytes: peer1,
Capacity: 100000,
RemoteBalance: 100000,
LocalBalance: 0,
}
chan2 = lndclient.ChannelInfo{
ChannelID: chanID2.ToUint64(),
PubKeyBytes: peer2,
Capacity: 200000,
RemoteBalance: 200000,
LocalBalance: 0,
}
channels = []lndclient.ChannelInfo{
chan1, chan2,
}
// Create a rule which will loop in, with no inbound liquidity
// reserve.
rule = &SwapRule{
ThresholdRule: NewThresholdRule(0, 60),
Type: swap.TypeIn,
}
// Under these rules, we'll have the following recommended
// swaps:
peer1ExpectedAmt btcutil.Amount = 80000
peer2ExpectedAmt btcutil.Amount = 160000
// Set our per-swap budget to 5% of swap amount.
swapFeePPM uint64 = 50000
htlcConfTarget int32 = 10
// Calculate the maximum amount we'll pay for each swap and
// set our budget to be able to accommodate both.
peer1MaxFee = ppmToSat(peer1ExpectedAmt, swapFeePPM)
peer2MaxFee = ppmToSat(peer2ExpectedAmt, swapFeePPM)
params = Parameters{
Autoloop: true,
AutoFeeBudget: peer1MaxFee + peer2MaxFee + 1,
AutoFeeRefreshPeriod: testBudgetRefresh,
AutoloopBudgetLastRefresh: testBudgetStart,
MaxAutoInFlight: 2,
FailureBackOff: time.Hour,
FeeLimit: NewFeePortion(swapFeePPM),
ChannelRules: make(map[lnwire.ShortChannelID]*SwapRule),
PeerRules: map[route.Vertex]*SwapRule{
peer1: rule,
peer2: rule,
},
HtlcConfTarget: htlcConfTarget,
SweepConfTarget: loop.DefaultSweepConfTarget,
}
)
c := newAutoloopTestCtx(t, params, channels, testRestrictions)
c.start()
// Calculate our maximum allowed fees and create quotes that fall within
// our budget.
var (
quote1 = &loop.LoopInQuote{
SwapFee: peer1MaxFee / 4,
MinerFee: peer1MaxFee / 8,
}
quote2Unaffordable = &loop.LoopInQuote{
SwapFee: peer2MaxFee * 2,
MinerFee: peer2MaxFee * 1000,
}
quoteRequest1 = &loop.LoopInQuoteRequest{
Amount: peer1ExpectedAmt,
HtlcConfTarget: htlcConfTarget,
LastHop: &peer1,
}
quoteRequest2 = &loop.LoopInQuoteRequest{
Amount: peer2ExpectedAmt,
HtlcConfTarget: htlcConfTarget,
LastHop: &peer2,
}
peer1Swap = &loop.LoopInRequest{
Amount: peer1ExpectedAmt,
MaxSwapFee: quote1.SwapFee,
MaxMinerFee: quote1.MinerFee,
HtlcConfTarget: htlcConfTarget,
LastHop: &peer1,
ExternalHtlc: false,
Label: labels.AutoloopLabel(swap.TypeIn),
Initiator: autoloopSwapInitiator,
}
)
// Tick our autolooper with no existing swaps. Both of our peers
// require swaps, but one of our peer's quotes is too expensive.
step := &autoloopStep{
minAmt: 1,
maxAmt: peer2ExpectedAmt + 1,
quotesIn: []quoteInRequestResp{
{
request: quoteRequest1,
quote: quote1,
},
{
request: quoteRequest2,
quote: quote2Unaffordable,
},
},
expectedIn: []loopInRequestResp{
{
request: peer1Swap,
response: &loop.LoopInSwapInfo{
SwapHash: lntypes.Hash{1},
},
},
},
}
c.autoloop(step)
// Now, we tick again with our first swap in progress. This time, we
// provide a quote for our second swap which is more affordable, so we
// expect it to be dispatched.
var (
quote2Affordable = &loop.LoopInQuote{
SwapFee: peer2MaxFee / 8,
MinerFee: peer2MaxFee / 2,
}
peer2Swap = &loop.LoopInRequest{
Amount: peer2ExpectedAmt,
MaxSwapFee: quote2Affordable.SwapFee,
MaxMinerFee: quote2Affordable.MinerFee,
HtlcConfTarget: htlcConfTarget,
LastHop: &peer2,
ExternalHtlc: false,
Label: labels.AutoloopLabel(swap.TypeIn),
Initiator: autoloopSwapInitiator,
}
existing = []*loopdb.LoopIn{
existingInFromRequest(peer1Swap, testTime, nil),
}
)
step = &autoloopStep{
minAmt: 1,
maxAmt: peer2ExpectedAmt + 1,
quotesIn: []quoteInRequestResp{
{
request: quoteRequest2,
quote: quote2Affordable,
},
},
existingIn: existing,
expectedIn: []loopInRequestResp{
{
request: peer2Swap,
response: &loop.LoopInSwapInfo{
SwapHash: lntypes.Hash{2},
},
},
},
}
c.autoloop(step)
c.stop()
}
// TestAutoloopBothTypes tests dispatching of a loop out and loop in swap at the
// same time.
func TestAutoloopBothTypes(t *testing.T) {
defer test.Guard(t)()
var (
chan1 = lndclient.ChannelInfo{
ChannelID: chanID1.ToUint64(),
PubKeyBytes: peer1,
Capacity: 1000000,
LocalBalance: 1000000,
}
chan2 = lndclient.ChannelInfo{
ChannelID: chanID2.ToUint64(),
PubKeyBytes: peer2,
Capacity: 200000,
RemoteBalance: 200000,
LocalBalance: 0,
}
channels = []lndclient.ChannelInfo{
chan1, chan2,
}
// Create a rule which will loop out, with no outbound liquidity
// reserve.
outRule = &SwapRule{
ThresholdRule: NewThresholdRule(40, 0),
Type: swap.TypeOut,
}
// Create a rule which will loop in, with no inbound liquidity
// reserve.
inRule = &SwapRule{
ThresholdRule: NewThresholdRule(0, 60),
Type: swap.TypeIn,
}
// Under this rule, we expect a loop in swap.
loopOutAmt btcutil.Amount = 700000
loopInAmount btcutil.Amount = 160000
// Set our per-swap budget to 5% of swap amount.
swapFeePPM uint64 = 50000
htlcConfTarget int32 = 10
// Calculate the maximum amount we'll pay for our loop in.
loopOutMaxFee = ppmToSat(loopOutAmt, swapFeePPM)
loopInMaxFee = ppmToSat(loopInAmount, swapFeePPM)
params = Parameters{
Autoloop: true,
AutoFeeBudget: loopOutMaxFee + loopInMaxFee + 1,
AutoFeeRefreshPeriod: testBudgetRefresh,
AutoloopBudgetLastRefresh: testBudgetStart,
MaxAutoInFlight: 2,
FailureBackOff: time.Hour,
FeeLimit: NewFeePortion(swapFeePPM),
ChannelRules: map[lnwire.ShortChannelID]*SwapRule{
chanID1: outRule,
},
PeerRules: map[route.Vertex]*SwapRule{
peer2: inRule,
},
HtlcConfTarget: htlcConfTarget,
SweepConfTarget: loop.DefaultSweepConfTarget,
}
)
c := newAutoloopTestCtx(t, params, channels, testRestrictions)
c.start()
// Calculate our maximum allowed fees and create quotes that fall within
// our budget.
var (
loopOutQuote = &loop.LoopOutQuote{
SwapFee: loopOutMaxFee / 4,
PrepayAmount: loopOutMaxFee / 4,
}
loopOutQuoteReq = &loop.LoopOutQuoteRequest{
Amount: loopOutAmt,
SweepConfTarget: params.SweepConfTarget,
SwapPublicationDeadline: testTime,
}
prepayMaxFee, routeMaxFee,
minerFee = params.FeeLimit.loopOutFees(
loopOutAmt, loopOutQuote,
)
loopOutSwap = &loop.OutRequest{
Amount: loopOutAmt,
MaxSwapRoutingFee: routeMaxFee,
MaxPrepayRoutingFee: prepayMaxFee,
MaxSwapFee: loopOutQuote.SwapFee,
MaxPrepayAmount: loopOutQuote.PrepayAmount,
MaxMinerFee: minerFee,
SweepConfTarget: params.SweepConfTarget,
OutgoingChanSet: loopdb.ChannelSet{
chanID1.ToUint64(),
},
Label: labels.AutoloopLabel(swap.TypeOut),
Initiator: autoloopSwapInitiator,
}
loopinQuote = &loop.LoopInQuote{
SwapFee: loopInMaxFee / 4,
MinerFee: loopInMaxFee / 8,
}
loopInQuoteReq = &loop.LoopInQuoteRequest{
Amount: loopInAmount,
HtlcConfTarget: htlcConfTarget,
LastHop: &peer2,
}
loopInSwap = &loop.LoopInRequest{
Amount: loopInAmount,
MaxSwapFee: loopinQuote.SwapFee,
MaxMinerFee: loopinQuote.MinerFee,
HtlcConfTarget: htlcConfTarget,
LastHop: &peer2,
ExternalHtlc: false,
Label: labels.AutoloopLabel(swap.TypeIn),
Initiator: autoloopSwapInitiator,
}
singleLoopOut = &loopdb.LoopOut{
Loop: loopdb.Loop{
Events: []*loopdb.LoopEvent{
{
SwapStateData: loopdb.SwapStateData{
State: loopdb.StateSuccess,
},
},
},
},
}
)
step := &autoloopStep{
minAmt: 1,
maxAmt: loopOutAmt + 1,
quotesOut: []quoteRequestResp{
{
request: loopOutQuoteReq,
quote: loopOutQuote,
},
},
quotesIn: []quoteInRequestResp{
{
request: loopInQuoteReq,
quote: loopinQuote,
},
},
expectedOut: []loopOutRequestResp{
{
request: loopOutSwap,
response: &loop.LoopOutSwapInfo{
SwapHash: lntypes.Hash{1},
},
},
},
expectedIn: []loopInRequestResp{
{
request: loopInSwap,
response: &loop.LoopInSwapInfo{
SwapHash: lntypes.Hash{2},
},
},
},
existingOutSingle: singleLoopOut,
}
c.autoloop(step)
c.stop()
}
// TestAutoLoopRecurringBudget tests that the autolooper will perform swaps that
// respect the fee budget, and that it will refresh the budget based on the
// defined refresh period.
func TestAutoLoopRecurringBudget(t *testing.T) {
defer test.Guard(t)()
var (
channels = []lndclient.ChannelInfo{
channel1, channel2,
}
swapFeePPM uint64 = 1000
routeFeePPM uint64 = 1000
prepayFeePPM uint64 = 1000
prepayAmount = btcutil.Amount(20000)
maxMiner = btcutil.Amount(20000)
params = Parameters{
Autoloop: true,
AutoFeeBudget: 36000,
AutoFeeRefreshPeriod: time.Hour * 3,
AutoloopBudgetLastRefresh: testBudgetStart,
MaxAutoInFlight: 2,
FailureBackOff: time.Hour,
SweepConfTarget: 10,
FeeLimit: NewFeeCategoryLimit(
swapFeePPM, routeFeePPM, prepayFeePPM, maxMiner,
prepayAmount, 20000,
),
ChannelRules: map[lnwire.ShortChannelID]*SwapRule{
chanID1: chanRule,
chanID2: chanRule,
},
HtlcConfTarget: defaultHtlcConfTarget,
}
)
c := newAutoloopTestCtx(t, params, channels, testRestrictions)
c.start()
// Calculate our maximum allowed fees and create quotes that fall within
// our budget.
var (
amt = chan1Rec.Amount
maxSwapFee = ppmToSat(amt, swapFeePPM)
// 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: prepayAmount - 10,
MinerFee: maxMiner - 10,
}
quote2 = &loop.LoopOutQuote{
SwapFee: maxSwapFee,
PrepayAmount: prepayAmount - 20,
MinerFee: maxMiner - 10,
}
quoteRequest = &loop.LoopOutQuoteRequest{
Amount: amt,
SweepConfTarget: params.SweepConfTarget,
}
quotes1 = []quoteRequestResp{
{
request: quoteRequest,
quote: quote1,
},
{
request: quoteRequest,
quote: quote2,
},
}
quotes2 = []quoteRequestResp{
{
request: quoteRequest,
quote: quote2,
},
}
maxRouteFee = ppmToSat(amt, routeFeePPM)
chan1Swap = &loop.OutRequest{
Amount: amt,
MaxSwapRoutingFee: maxRouteFee,
MaxPrepayRoutingFee: ppmToSat(
quote1.PrepayAmount, prepayFeePPM,
),
MaxSwapFee: quote1.SwapFee,
MaxPrepayAmount: quote1.PrepayAmount,
MaxMinerFee: maxMiner,
SweepConfTarget: params.SweepConfTarget,
OutgoingChanSet: loopdb.ChannelSet{chanID1.ToUint64()},
Label: labels.AutoloopLabel(swap.TypeOut),
Initiator: autoloopSwapInitiator,
}
chan2Swap = &loop.OutRequest{
Amount: amt,
MaxSwapRoutingFee: maxRouteFee,
MaxPrepayRoutingFee: ppmToSat(
quote2.PrepayAmount, routeFeePPM,
),
MaxSwapFee: quote2.SwapFee,
MaxPrepayAmount: quote2.PrepayAmount,
MaxMinerFee: maxMiner,
SweepConfTarget: params.SweepConfTarget,
OutgoingChanSet: loopdb.ChannelSet{chanID2.ToUint64()},
Label: labels.AutoloopLabel(swap.TypeOut),
Initiator: autoloopSwapInitiator,
}
loopOuts1 = []loopOutRequestResp{
{
request: chan1Swap,
response: &loop.LoopOutSwapInfo{
SwapHash: lntypes.Hash{1},
},
},
}
loopOuts2 = []loopOutRequestResp{
{
request: chan2Swap,
response: &loop.LoopOutSwapInfo{
SwapHash: lntypes.Hash{1},
},
},
}
singleLoopOut = &loopdb.LoopOut{
Loop: loopdb.Loop{
Events: []*loopdb.LoopEvent{
{
SwapStateData: loopdb.SwapStateData{
State: loopdb.StateSuccess,
},
},
},
},
}
)
// Tick our autolooper with no existing swaps, we expect a loop out
// swap to be dispatched on first channel.
step := &autoloopStep{
minAmt: 1,
maxAmt: amt + 1,
quotesOut: quotes1,
expectedOut: loopOuts1,
existingOutSingle: singleLoopOut,
}
c.autoloop(step)
existing := []*loopdb.LoopOut{
existingSwapFromRequest(
chan1Swap, testTime, []*loopdb.LoopEvent{
{
SwapStateData: loopdb.SwapStateData{
State: loopdb.StateInitiated,
},
Time: testTime,
},
},
),
}
step = &autoloopStep{
minAmt: 1,
maxAmt: amt + 1,
quotesOut: quotes2,
existingOut: existing,
expectedOut: nil,
existingOutSingle: singleLoopOut,
}
// Tick again, we should expect no loop outs because our budget would be
// exceeded.
c.autoloop(step)
// Create the existing entry for the first swap, marking its last update
// with success and a specific timestamp.
existing2 := []*loopdb.LoopOut{
existingSwapFromRequest(
chan1Swap, testTime, []*loopdb.LoopEvent{
{
SwapStateData: loopdb.SwapStateData{
State: loopdb.StateSuccess,
},
Time: testTime,
},
},
),
}
// Apply the balance shifts on the channels in order to get the correct
// recommendations on next tick.
c.lnd.Channels[0].LocalBalance = 2500
c.lnd.Channels[0].RemoteBalance = 7500
// Advance time to the future, causing a budget refresh.
c.testClock.SetTime(testTime.Add(time.Hour * 25))
step = &autoloopStep{
minAmt: 1,
maxAmt: amt + 1,
quotesOut: quotes2,
existingOut: existing2,
expectedOut: loopOuts2,
existingOutSingle: singleLoopOut,
}
// Tick again, we should expect a loop out to occur on the 2nd channel.
c.autoloop(step)
c.stop()
}
// TestEasyAutoloop tests that the easy autoloop logic works as expected. This
// involves testing that channels are correctly selected and that the balance
// target is successfully met.
func TestEasyAutoloop(t *testing.T) {
defer test.Guard(t)
// Decode a dummy p2wkh address to use as the destination address for
// the swaps.
p2wkhAddr := "bcrt1qq68r6ff4k4pjx39efs44gcyccf7unqnu5qtjjz"
addr, err := btcutil.DecodeAddress(p2wkhAddr, nil)
if err != nil {
t.Error(err)
}
// We need to change the default channels we use for tests so that they
// have different local balances in order to know which one is going to
// be selected by easy autoloop.
easyChannel1 := lndclient.ChannelInfo{
Active: true,
ChannelID: chanID1.ToUint64(),
PubKeyBytes: peer1,
LocalBalance: 95000,
RemoteBalance: 0,
Capacity: 100000,
}
easyChannel2 := lndclient.ChannelInfo{
Active: true,
ChannelID: chanID1.ToUint64(),
PubKeyBytes: peer1,
LocalBalance: 75000,
RemoteBalance: 0,
Capacity: 100000,
}
var (
channels = []lndclient.ChannelInfo{
easyChannel1, easyChannel2,
}
params = Parameters{
Autoloop: true,
DestAddr: addr,
AutoFeeBudget: 36000,
AutoFeeRefreshPeriod: time.Hour * 3,
AutoloopBudgetLastRefresh: testBudgetStart,
MaxAutoInFlight: 2,
FailureBackOff: time.Hour,
SweepConfTarget: 10,
HtlcConfTarget: defaultHtlcConfTarget,
EasyAutoloop: true,
EasyAutoloopTarget: 75000,
FeeLimit: defaultFeePortion(),
}
)
c := newAutoloopTestCtx(t, params, channels, testRestrictions)
c.start()
var (
maxAmt = 50000
chan1Swap = &loop.OutRequest{
Amount: btcutil.Amount(maxAmt),
DestAddr: addr,
OutgoingChanSet: loopdb.ChannelSet{easyChannel1.ChannelID},
Label: labels.AutoloopLabel(swap.TypeOut),
Initiator: autoloopSwapInitiator,
}
quotesOut1 = []quoteRequestResp{
{
request: &loop.LoopOutQuoteRequest{
Amount: btcutil.Amount(maxAmt),
},
quote: &loop.LoopOutQuote{
SwapFee: 1,
PrepayAmount: 1,
MinerFee: 1,
},
},
}
loopOut1 = []loopOutRequestResp{
{
request: chan1Swap,
response: &loop.LoopOutSwapInfo{
SwapHash: lntypes.Hash{1},
},
},
}
)
// We expected one max size swap to be dispatched on our channel with
// the biggest local balance.
step := &easyAutoloopStep{
minAmt: 1,
maxAmt: 50000,
quotesOut: quotesOut1,
expectedOut: loopOut1,
}
c.easyautoloop(step, false)
c.stop()
// In order to reflect the change on the channel balances we create a
// new context and restart the autolooper.
easyChannel1.LocalBalance -= chan1Swap.Amount
channels = []lndclient.ChannelInfo{
easyChannel1, easyChannel2,
}
// Remove the custom dest address.
params.DestAddr = nil
c = newAutoloopTestCtx(t, params, channels, testRestrictions)
c.start()
var (
amt2 = 45_000
chan2Swap = &loop.OutRequest{
Amount: btcutil.Amount(amt2),
OutgoingChanSet: loopdb.ChannelSet{easyChannel2.ChannelID},
Label: labels.AutoloopLabel(swap.TypeOut),
Initiator: autoloopSwapInitiator,
}
quotesOut2 = []quoteRequestResp{
{
request: &loop.LoopOutQuoteRequest{
Amount: btcutil.Amount(amt2),
},
quote: &loop.LoopOutQuote{
SwapFee: 1,
PrepayAmount: 1,
MinerFee: 1,
},
},
}
loopOut2 = []loopOutRequestResp{
{
request: chan2Swap,
response: &loop.LoopOutSwapInfo{
SwapHash: lntypes.Hash{1},
},
},
}
)
// We expect a swap of size 45_000 to be dispatched in order to meet the
// defined target of 75_000.
step = &easyAutoloopStep{
minAmt: 1,
maxAmt: 50000,
quotesOut: quotesOut2,
expectedOut: loopOut2,
}
c.easyautoloop(step, false)
c.stop()
// In order to reflect the change on the channel balances we create a
// new context and restart the autolooper.
easyChannel2.LocalBalance -= btcutil.Amount(amt2)
channels = []lndclient.ChannelInfo{
easyChannel1, easyChannel2,
}
c = newAutoloopTestCtx(t, params, channels, testRestrictions)
c.start()
// We have met the target of 75_000 so we don't expect any action from
// easy autoloop. That's why we set noop to true in the call below.
step = &easyAutoloopStep{
minAmt: 1,
maxAmt: 50000,
}
c.easyautoloop(step, true)
c.stop()
// Restore the local balance to a higher value that will trigger a swap.
easyChannel2.LocalBalance = btcutil.Amount(95000)
channels = []lndclient.ChannelInfo{
easyChannel1, easyChannel2,
}
// Override the feeppm with a lower one.
params.FeeLimit = NewFeePortion(5)
c = newAutoloopTestCtx(t, params, channels, testRestrictions)
c.start()
// Even though there should be a swap dispatched in order to meet the
// local balance target, we expect no action as the user defined feeppm
// should not be sufficient for the swap to be dispatched.
step = &easyAutoloopStep{
minAmt: 1,
maxAmt: 50000,
// Since we have the exact same balance as the first step, we
// can reuse the quoteOut1 for the expected loop out quote.
quotesOut: quotesOut1,
expectedOut: nil,
}
c.easyautoloop(step, false)
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,
},
}
}
func existingInFromRequest(in *loop.LoopInRequest, initTime time.Time,
events []*loopdb.LoopEvent) *loopdb.LoopIn {
return &loopdb.LoopIn{
Loop: loopdb.Loop{
Events: events,
},
Contract: &loopdb.LoopInContract{
SwapContract: loopdb.SwapContract{
MaxSwapFee: in.MaxSwapFee,
MaxMinerFee: in.MaxMinerFee,
InitiationTime: initTime,
Label: in.Label,
},
HtlcConfTarget: in.HtlcConfTarget,
LastHop: in.LastHop,
ExternalHtlc: in.ExternalHtlc,
},
}
}