package liquidity

import (
	"context"
	"encoding/hex"
	"encoding/json"
	"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/lightninglabs/taproot-assets/rfqmsg"
	"github.com/lightningnetwork/lnd/lntypes"
	"github.com/lightningnetwork/lnd/lnwire"
	"github.com/lightningnetwork/lnd/routing/route"
	"github.com/stretchr/testify/require"
)

const p2wkhAddr = "bcrt1qq68r6ff4k4pjx39efs44gcyccf7unqnu5qtjjz"

// 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,
			FastSwapPublication: true,
		}
	)

	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.
	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,
			FastSwapPublication: true,
		}
	)
	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,
			FastSwapPublication: true,
		}
	)

	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,
			FastSwapPublication: false,
		}
	)
	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: c.testClock.Now().Add(
				defaultSwapPublicationWaitTime,
			),
		}

		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,
			SwapPublicationDeadline: c.testClock.Now().Add(
				defaultSwapPublicationWaitTime,
			),
		}

		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,
			FastSwapPublication: true,
		}
	)

	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.
	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,
	}

	// The custom channel should be ignored.
	easyChannelCustom := lndclient.ChannelInfo{
		Active:            true,
		ChannelID:         chanID1.ToUint64(),
		PubKeyBytes:       peer1,
		LocalBalance:      50000,
		RemoteBalance:     0,
		Capacity:          100000,
		CustomChannelData: []byte("foo"),
	}

	var (
		channels = []lndclient.ChannelInfo{
			easyChannel1, easyChannel2, easyChannelCustom,
		}

		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(),
			FastSwapPublication:       true,
		}
	)

	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, easyChannelCustom,
	}

	// 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, easyChannelCustom,
	}

	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, easyChannelCustom,
	}

	// 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,
		},
	}
}

// TestEasyAssetAutoloop tests that the easy asset autoloop logic works as
// expected. This involves testing that channels are correctly selected and
// that the balance target is successfully met.
func TestEasyAssetAutoloop(t *testing.T) {
	defer test.Guard(t)

	// Common variables for asset tests.
	assetId := [32]byte{0x01}
	assetStr := hex.EncodeToString(assetId[:])
	addr, err := btcutil.DecodeAddress(p2wkhAddr, nil)
	require.NoError(t, err)

	// Sub-test 1: Single asset channel.
	t.Run("single asset channel", func(t *testing.T) {
		// Prepare a channel with asset custom data.
		customChanData := rfqmsg.JsonAssetChannel{
			FundingAssets: []rfqmsg.JsonAssetUtxo{
				{
					AssetGenesis: rfqmsg.JsonAssetGenesis{
						AssetID: assetStr,
					},
				},
			},
			LocalBalance:  950000,
			RemoteBalance: 0,
			Capacity:      100000,
		}
		customChanDataBytes, err := json.Marshal(customChanData)
		require.NoError(t, err)

		assetChan := lndclient.ChannelInfo{
			Active:            true,
			ChannelID:         chanID1.ToUint64(),
			PubKeyBytes:       peer1,
			LocalBalance:      95000,
			RemoteBalance:     0,
			Capacity:          100000,
			CustomChannelData: customChanDataBytes,
		}

		channels := []lndclient.ChannelInfo{assetChan}
		params := Parameters{
			Autoloop:                  true,
			DestAddr:                  addr,
			AutoFeeBudget:             36000,
			AutoFeeRefreshPeriod:      time.Hour * 3,
			AutoloopBudgetLastRefresh: testBudgetStart,
			MaxAutoInFlight:           2,
			FailureBackOff:            time.Hour,
			SweepConfTarget:           10,
			HtlcConfTarget:            defaultHtlcConfTarget,
			FeeLimit:                  defaultFeePortion(),
			AssetAutoloopParams: map[string]AssetParams{
				assetStr: {
					EnableEasyOut:          true,
					LocalTargetAssetAmount: 75000,
				},
			},
		}

		c := newAutoloopTestCtx(t, params, channels, testRestrictions)
		// For testing, simply return asset units 1:1 to satoshis.
		assetPriceFunc := func(ctx context.Context, assetId string,
			peerPubkey []byte, assetAmt uint64, minSatAmt btcutil.Amount) (
			btcutil.Amount, error) {

			return btcutil.Amount(assetAmt), nil
		}
		c.manager.cfg.GetAssetPrice = assetPriceFunc
		c.start()

		// In this scenario we expect a swap of maxAmt (here chosen as 50000)
		// on our single asset channel.
		maxAmt := 50000
		chanSwap := &loop.OutRequest{
			Amount:          btcutil.Amount(maxAmt),
			DestAddr:        addr,
			OutgoingChanSet: loopdb.ChannelSet{assetChan.ChannelID},
			Label:           labels.AutoloopLabel(swap.TypeOut),
			Initiator:       autoloopSwapInitiator,
		}
		quotesOut := []quoteRequestResp{
			{
				request: &loop.LoopOutQuoteRequest{
					Amount: btcutil.Amount(maxAmt),
					AssetRFQRequest: &loop.AssetRFQRequest{
						AssetId:       assetId[:],
						AssetEdgeNode: []byte("edge"),
					},
				},
				quote: &loop.LoopOutQuote{
					SwapFee:      1,
					PrepayAmount: 1,
					MinerFee:     1,
					LoopOutRfq: &loop.LoopOutRfq{
						PrepayRfqId: []byte("prepay"),
						SwapRfqId:   []byte("swap"),
					},
				},
			},
		}
		expectedOut := []loopOutRequestResp{
			{
				request: chanSwap,
				response: &loop.LoopOutSwapInfo{
					SwapHash: lntypes.Hash{1},
				},
			},
		}
		step := &easyAutoloopStep{
			minAmt:      1,
			maxAmt:      btcutil.Amount(maxAmt),
			quotesOut:   quotesOut,
			expectedOut: expectedOut,
		}
		c.easyautoloop(step, false)
		c.stop()
	})

	// Sub-test 2: Two asset channels.
	t.Run("two asset channels", func(t *testing.T) {
		// Reuse the same custom channel data for both channels.
		customChanData := rfqmsg.JsonAssetChannel{
			FundingAssets: []rfqmsg.JsonAssetUtxo{
				{
					AssetGenesis: rfqmsg.JsonAssetGenesis{
						AssetID: assetStr,
					},
				},
			},
			LocalBalance:  950000,
			RemoteBalance: 0,
			Capacity:      100000,
		}
		customChanDataBytes1, err := json.Marshal(customChanData)
		require.NoError(t, err)

		customChanData.LocalBalance = 1050000
		customChanDataBytes2, err := json.Marshal(customChanData)
		require.NoError(t, err)

		// Create two asset channels with different local balances.
		assetChan1 := lndclient.ChannelInfo{
			Active:            true,
			ChannelID:         chanID1.ToUint64(),
			PubKeyBytes:       peer1,
			CustomChannelData: customChanDataBytes1,
		}
		assetChan2 := lndclient.ChannelInfo{
			Active:            true,
			ChannelID:         chanID2.ToUint64(), // different channel ID
			PubKeyBytes:       peer2,
			CustomChannelData: customChanDataBytes2,
		}

		channels := []lndclient.ChannelInfo{assetChan1, assetChan2}
		params := Parameters{
			Autoloop:                  true,
			DestAddr:                  addr,
			AutoFeeBudget:             36000,
			AutoFeeRefreshPeriod:      time.Hour * 3,
			AutoloopBudgetLastRefresh: testBudgetStart,
			MaxAutoInFlight:           2,
			FailureBackOff:            time.Hour,
			SweepConfTarget:           10,
			HtlcConfTarget:            defaultHtlcConfTarget,
			FeeLimit:                  defaultFeePortion(),
			AssetAutoloopParams: map[string]AssetParams{
				assetStr: {
					EnableEasyOut:          true,
					LocalTargetAssetAmount: 75000,
				},
			},
		}

		c := newAutoloopTestCtx(t, params, channels, testRestrictions)
		assetPriceFunc := func(ctx context.Context, assetId string,
			peerPubkey []byte, assetAmt uint64, minSatAmt btcutil.Amount) (
			btcutil.Amount, error) {

			return btcutil.Amount(assetAmt), nil
		}
		c.manager.cfg.GetAssetPrice = assetPriceFunc
		c.start()

		// Expect a swap on the channel with the higher local balance (assetChan2).
		maxAmt := 40000
		chanSwap := &loop.OutRequest{
			Amount:          btcutil.Amount(maxAmt),
			DestAddr:        addr,
			OutgoingChanSet: loopdb.ChannelSet{assetChan2.ChannelID},
			Label:           labels.AutoloopLabel(swap.TypeOut),
			Initiator:       autoloopSwapInitiator,
		}
		quotesOut := []quoteRequestResp{
			{
				request: &loop.LoopOutQuoteRequest{
					Amount: btcutil.Amount(maxAmt),
					AssetRFQRequest: &loop.AssetRFQRequest{
						AssetId:       assetId[:],
						AssetEdgeNode: []byte("edge"),
					},
				},
				quote: &loop.LoopOutQuote{
					SwapFee:      1,
					PrepayAmount: 1,
					MinerFee:     1,
					LoopOutRfq: &loop.LoopOutRfq{
						PrepayRfqId: []byte("prepay"),
						SwapRfqId:   []byte("swap"),
					},
				},
			},
		}
		expectedOut := []loopOutRequestResp{
			{
				request: chanSwap,
				response: &loop.LoopOutSwapInfo{
					SwapHash: lntypes.Hash{1},
				},
			},
		}
		step := &easyAutoloopStep{
			minAmt:      1,
			maxAmt:      btcutil.Amount(maxAmt),
			quotesOut:   quotesOut,
			expectedOut: expectedOut,
		}
		c.easyautoloop(step, false)
		c.stop()
	})

	// Sub-test 3: Mixed asset and non-asset channels.
	t.Run("non asset and normal channel", func(t *testing.T) {
		// Create an asset channel with custom asset data.
		customChanData := rfqmsg.JsonAssetChannel{
			FundingAssets: []rfqmsg.JsonAssetUtxo{
				{
					AssetGenesis: rfqmsg.JsonAssetGenesis{
						AssetID: assetStr,
					},
				},
			},
			LocalBalance:  950000,
			RemoteBalance: 0,
			Capacity:      100000,
		}
		customChanDataBytes, err := json.Marshal(customChanData)
		require.NoError(t, err)
		assetChan := lndclient.ChannelInfo{
			Active:            true,
			ChannelID:         chanID1.ToUint64(),
			PubKeyBytes:       peer1,
			LocalBalance:      95000,
			RemoteBalance:     0,
			Capacity:          100000,
			CustomChannelData: customChanDataBytes,
		}

		// Create a normal channel (no custom channel data).
		normalChan := lndclient.ChannelInfo{
			Active:        true,
			ChannelID:     chanID2.ToUint64(),
			PubKeyBytes:   peer1,
			LocalBalance:  100000,
			RemoteBalance: 0,
			Capacity:      100000,
		}

		channels := []lndclient.ChannelInfo{assetChan, normalChan}
		params := Parameters{
			Autoloop:                  true,
			DestAddr:                  addr,
			AutoFeeBudget:             36000,
			AutoFeeRefreshPeriod:      time.Hour * 3,
			AutoloopBudgetLastRefresh: testBudgetStart,
			MaxAutoInFlight:           2,
			FailureBackOff:            time.Hour,
			SweepConfTarget:           10,
			HtlcConfTarget:            defaultHtlcConfTarget,
			FeeLimit:                  defaultFeePortion(),
			AssetAutoloopParams: map[string]AssetParams{
				assetStr: {
					EnableEasyOut:          true,
					LocalTargetAssetAmount: 75000,
				},
			},
		}

		c := newAutoloopTestCtx(t, params, channels, testRestrictions)
		assetPriceFunc := func(ctx context.Context, assetId string,
			peerPubkey []byte, assetAmt uint64, minSatAmt btcutil.Amount) (
			btcutil.Amount, error) {

			return btcutil.Amount(assetAmt), nil
		}
		c.manager.cfg.GetAssetPrice = assetPriceFunc
		c.start()

		maxAmtAsset := 50000

		assetSwap := &loop.OutRequest{
			Amount:          btcutil.Amount(maxAmtAsset),
			DestAddr:        addr,
			OutgoingChanSet: loopdb.ChannelSet{assetChan.ChannelID},
			Label:           labels.AutoloopLabel(swap.TypeOut),
			Initiator:       autoloopSwapInitiator,
		}
		quotesOut := []quoteRequestResp{
			{
				request: &loop.LoopOutQuoteRequest{
					Amount: btcutil.Amount(maxAmtAsset),
					AssetRFQRequest: &loop.AssetRFQRequest{
						AssetId:       assetId[:],
						AssetEdgeNode: []byte("edge"),
					},
				},
				quote: &loop.LoopOutQuote{
					SwapFee:      1,
					PrepayAmount: 1,
					MinerFee:     1,
					LoopOutRfq: &loop.LoopOutRfq{
						PrepayRfqId: []byte("prepay"),
						SwapRfqId:   []byte("swap"),
					},
				},
			},
		}
		expectedOut := []loopOutRequestResp{
			{
				request: assetSwap,
				response: &loop.LoopOutSwapInfo{
					SwapHash: lntypes.Hash{1},
				},
			},
		}
		step := &easyAutoloopStep{
			minAmt:      1,
			maxAmt:      50000,
			quotesOut:   quotesOut,
			expectedOut: expectedOut,
		}
		c.easyautoloop(step, false)
		c.stop()
	})
}
