Repository: lightninglabs/taproot-assets

Issue:
[feature]: Multi-RFQ receive
## Description

When generating an invoice, we are restricted to a specific assetID and a single peer. Even if that peer agreed on an RFQ quote with us, they may never be able to forward the payment to us because of their inbound. We should investigate ways of encoding multiple RFQs / entry-points to tapd and escape the single link bottleneck.

Similar to [Multi-RFQ send](https://github.com/lightninglabs/taproot-assets/issues/1358), but a bit more difficult as we need to create a bolt11 compatible invoice that will be payable by all LN implementations, so we can't pull a lot of custom tricks. Multi-RFQ send has the convenience of being resolved within the scope of the local `tapd`<>`lnd`.

Items to investigate:

- [x] Even if we did acquire multiple RFQs before creating the invoice, how can we encode them all in the Bolt11 format
- [ ] ~Use blinded paths, which can give up to 3 entry points (possibility for 3 RFQs?)~
- [ ] ~Present multiple RFQ with interactive bolt12 offer communication~

Hints from issue comments:
@GeorgeTsagk: Bolt11 route hints are imported to the graph used for pathfinding throughout the payment's lifecycle, as seen [lnd/routing/payment_session.go](https://github.com/lightningnetwork/lnd/blob/68105be1ebbd8cae18c74f112d85382702477d80/routing/payment_session.go#L205)

This means, that we should be able to acquire multiple quotes from our peers, and encode multiple RFQ scids in the invoice, giving more routing options to the sender@ZZiigguurraatt: It seems we have another motivation for this in that we can get a competitive bid from the peers as well and choose the peers with best rates to include in the route hints.@ZZiigguurraatt: We also need to consider multi path payments. Do we want to request a quote for the full invoice amount with each peer or do we want to intentionally request a lesser amount with each peer so that the sender MUST use multi part payments and split the payment across several peers?@ZZiigguurraatt: Another thing to consider is when defining the SATS amount in the invoice that is given to the sender, we need to use the worst quote of all the peers. In this case, if the sender happens to pay through the peer with the best quote, we will get some bonus assets.@GeorgeTsagk: > We also need to consider multi path payments. Do we want to request a quote for the full invoice amount with each peer or do we want to intentionally request a lesser amount with each peer so that the sender MUST use multi part payments and split the payment across several peers?

Yeah that's a good point, I think we definitely need to over-quote, otherwise the sender would never be able to guess the exact amounts for each hop.



> Another thing to consider is when defining the SATS amount in the invoice that is given to the sender, we need to use the worst quote of all the peers. In this case, if the sender happens to pay through the peer with the best quote, we will get some bonus assets.

Also great point, we'd have to have some smart logic involved here, perhaps pick the worse rate only if there's a small spread over the quotes. If a peer provides me with a quote that deviates by more than a certain percentage/threshold, I'd rather kick him out of the "quote set" than include him.@ZZiigguurraatt: > Also great point, we'd have to have some smart logic involved here, perhaps pick the worse rate only if there's a small spread over the quotes.

I think we always have to pick the worst quote regardless of the spread, but I agree that

> If a peer provides me with a quote that deviates by more than a certain percentage/threshold, I'd rather kick him out of the "quote set" than include him.

but say we have one quote that is way _better_ than all the others, we probably don't want to throw that one out just because it deviates a lot (unless we think that node is going to have low reliability of forwarding payments). It seems like we would want to keep it and if we are lucky and get a better deal then we'll take it. But  we do want to throw out quotes that are much **worse** than all the others.

Solution patch:
diff --git a/rfq/manager.go b/rfq/manager.go
index 125b0230..b1c124a8 100644
--- a/rfq/manager.go
+++ b/rfq/manager.go
@@ -6,6 +6,8 @@ import (
 	"encoding/json"
 	"errors"
 	"fmt"
+	"sort"
+	"strings"
 	"sync"
 	"time"
 
@@ -17,10 +19,12 @@ import (
 	"github.com/lightninglabs/taproot-assets/fn"
 	"github.com/lightninglabs/taproot-assets/rfqmath"
 	"github.com/lightninglabs/taproot-assets/rfqmsg"
-	lfn "github.com/lightningnetwork/lnd/fn/v2"
+	"github.com/lightninglabs/taproot-assets/taprpc/rfqrpc"
+	"github.com/lightningnetwork/lnd/lnrpc"
 	"github.com/lightningnetwork/lnd/lnutils"
 	"github.com/lightningnetwork/lnd/lnwire"
 	"github.com/lightningnetwork/lnd/routing/route"
+	"github.com/lightningnetwork/lnd/zpay32"
 )
 
 const (
@@ -41,7 +45,9 @@ const (
 type ChannelLister interface {
 	// ListChannels returns a list of channels that are available for
 	// routing.
-	ListChannels(ctx context.Context) ([]lndclient.ChannelInfo, error)
+	ListChannels(ctx context.Context, activeOnly, publicOnly bool,
+		_ ...lndclient.ListChannelsOption) ([]lndclient.ChannelInfo,
+		error)
 }
 
 // ScidAliasManager is an interface that can add short channel ID (SCID) aliases
@@ -546,63 +552,38 @@ func (m *Manager) handleOutgoingMessage(outgoingMsg rfqmsg.OutgoingMsg) error {
 func (m *Manager) addScidAlias(scidAlias uint64, assetSpecifier asset.Specifier,
 	peer route.Vertex) error {
 
-	// Retrieve all local channels.
 	ctxb := context.Background()
-	localChans, err := m.cfg.ChannelLister.ListChannels(ctxb)
-	if err != nil {
-		// Not being able to call lnd to add the alias is a critical
-		// error, which warrants shutting down, as something is wrong.
-		return fn.NewCriticalError(
-			fmt.Errorf("add alias: error listing local channels: "+
-				"%w", err),
-		)
-	}
-
-	// Filter for channels with the given peer.
-	peerChannels := lfn.Filter(
-		localChans, func(c lndclient.ChannelInfo) bool {
-			return c.PubKeyBytes == peer
-		},
+	peerChans, err := m.FetchChannel(
+		ctxb, assetSpecifier, &peer, NoIntention,
 	)
+	if err != nil && !strings.Contains(
+		err.Error(), "no asset channel balance found for",
+	) {
 
-	var baseSCID uint64
-	for _, localChan := range peerChannels {
-		if len(localChan.CustomChannelData) == 0 {
-			continue
-		}
-
-		var assetData rfqmsg.JsonAssetChannel
-		err = json.Unmarshal(localChan.CustomChannelData, &assetData)
-		if err != nil {
-			log.Warnf("Unable to unmarshal channel asset data: %v",
-				err)
-			continue
-		}
+		return err
+	}
 
-		match, err := m.ChannelMatchesFully(
-			ctxb, assetData, assetSpecifier,
+	// As a fallback, if we didn't find any compatible channels with the
+	// peer, let's pick any channel that is available with this peer. This
+	// is okay, because non-strict forwarding will ask each channel if the
+	// bandwidth matches the provided specifier.
+	if len(peerChans[peer]) == 0 {
+		peerChans, err = m.FetchChannel(
+			ctxb, asset.Specifier{}, &peer, NoIntention,
 		)
 		if err != nil {
 			return err
 		}
-
-		// TODO(george): Instead of returning the first result,
-		// try to pick the best channel for what we're trying to
-		// do (receive/send). Binding a baseSCID means we're
-		// also binding the asset liquidity on that channel.
-		if match {
-			baseSCID = localChan.ChannelID
-			break
-		}
 	}
 
-	// As a fallback, if the base SCID is not found and there's only one
-	// channel with the target peer, assume that the base SCID corresponds
-	// to that channel.
-	if baseSCID == 0 && len(peerChannels) == 1 {
-		baseSCID = peerChannels[0].ChannelID
+	if len(peerChans[peer]) == 0 {
+		return fmt.Errorf("cannot add scid alias with peer=%v, no "+
+			"compatible channels found for %s", peer,
+			&assetSpecifier)
 	}
 
+	baseSCID := peerChans[peer][0].ChannelInfo.ChannelID
+
 	// At this point, if the base SCID is still not found, we return an
 	// error. We can't map the SCID alias to a base SCID.
 	if baseSCID == 0 {
@@ -1052,6 +1033,250 @@ func (m *Manager) ChannelMatchesFully(ctx context.Context,
 	return true, nil
 }
 
+// TapChannel is a helper struct that combines the information of an asset
+// specifier that is satisfied by a channel with the channels' general
+// information.
+type TapChannel struct {
+	// Specifier is the asset Specifier that is satisfied by this channels'
+	// assets.
+	Specifier asset.Specifier
+
+	// ChannelInfo is the information about the channel the asset is
+	// committed to.
+	ChannelInfo lndclient.ChannelInfo
+
+	// AssetInfo contains the asset related info of the channel.
+	AssetInfo rfqmsg.JsonAssetChannel
+}
+
+// PeerChanMap is a structure that maps peers to channels. This is used for
+// filtering asset channels against an asset specifier.
+type PeerChanMap map[route.Vertex][]TapChannel
+
+// ComputeChannelAssetBalance computes the total local and remote balance for
+// each asset channel that matches the provided asset specifier.
+func (m *Manager) ComputeChannelAssetBalance(ctx context.Context,
+	activeChannels []lndclient.ChannelInfo,
+	specifier asset.Specifier) (PeerChanMap, bool, error) {
+
+	var (
+		peerChanMap              = make(PeerChanMap)
+		haveGroupedAssetChannels bool
+	)
+	for chanIdx := range activeChannels {
+		var (
+			pass      bool
+			assetData rfqmsg.JsonAssetChannel
+		)
+
+		openChan := activeChannels[chanIdx]
+
+		// If there specifier is empty, we skip all asset balance
+		// related checks.
+		if specifier.IsSome() {
+			if len(openChan.CustomChannelData) == 0 {
+				continue
+			}
+
+			err := json.Unmarshal(
+				openChan.CustomChannelData, &assetData,
+			)
+			if err != nil {
+				return nil, false, fmt.Errorf("unable to "+
+					"unmarshal asset data: %w", err)
+			}
+
+			if len(assetData.GroupKey) > 0 {
+				haveGroupedAssetChannels = true
+			}
+
+			// Check if the assets of this channel match the
+			// provided specifier.
+			pass, err = m.ChannelMatchesFully(
+				ctx, assetData, specifier,
+			)
+			if err != nil {
+				return nil, false, err
+			}
+		}
+
+		// We also append the channel in the case where the specifier is
+		// empty. This means that the caller doesn't really care about
+		// the type of balance.
+		if pass || !specifier.IsSome() {
+			peerChanMap[openChan.PubKeyBytes] = append(
+				peerChanMap[openChan.PubKeyBytes],
+				TapChannel{
+					Specifier:   specifier,
+					ChannelInfo: openChan,
+					AssetInfo:   assetData,
+				},
+			)
+		}
+	}
+
+	return peerChanMap, haveGroupedAssetChannels, nil
+}
+
+// ChanIntention defines the intention of calling rfqChannel. This helps with
+// returning the channel that is most suitable for what we want to do.
+type ChanIntention uint8
+
+const (
+	// NoIntention defines the absence of any intention, signalling that we
+	// don't really care which channel is returned.
+	NoIntention ChanIntention = iota
+
+	// SendIntention defines the intention to send over an asset channel.
+	SendIntention
+
+	// ReceiveIntention defines the intention to receive over an asset
+	// channel.
+	ReceiveIntention
+)
+
+// FetchChannel returns the channel to use for RFQ operations. It returns a map
+// of peers and their eligible channels. If a peerPubKey is specified then the
+// map will only contain one entry for that peer.
+func (m *Manager) FetchChannel(ctx context.Context, specifier asset.Specifier,
+	peerPubKey *route.Vertex,
+	intention ChanIntention) (PeerChanMap, error) {
+
+	activeChannels, err := m.cfg.ChannelLister.ListChannels(
+		ctx, true, false,
+	)
+	if err != nil {
+		return nil, err
+	}
+
+	balancesMap, haveGroupChans, err := m.ComputeChannelAssetBalance(
+		ctx, activeChannels, specifier,
+	)
+	if err != nil {
+		return nil, fmt.Errorf("error computing available asset "+
+			"channel balance: %w", err)
+	}
+
+	// If the user uses the asset ID to specify what asset to use, that will
+	// not work for asset channels that have multiple UTXOs of grouped
+	// assets. The result wouldn't be deterministic anyway (meaning, it's
+	// not guaranteed that a specific asset ID is moved within a channel
+	// when an HTLC is sent, the allocation logic decides which actual UTXO
+	// is used). So we tell the user to use the group key instead, at least
+	// for channels that have multiple UTXOs of grouped assets.
+	if specifier.HasId() && len(balancesMap) == 0 && haveGroupChans {
+		return nil, fmt.Errorf("no compatible asset channel found for "+
+			"%s, make sure to use group key for grouped asset "+
+			"channels", &specifier)
+	}
+
+	if len(balancesMap) == 0 {
+		return nil, fmt.Errorf("no asset channel balance found for %s",
+			&specifier)
+	}
+
+	switch intention {
+	case SendIntention:
+		// When sending we care about the volume of our local balances,
+		// so we sort by local balances in descending order.
+		for k, v := range balancesMap {
+			sort.Slice(v, func(i, j int) bool {
+				return v[i].AssetInfo.LocalBalance >
+					v[j].AssetInfo.LocalBalance
+			})
+
+			balancesMap[k] = v
+		}
+	case ReceiveIntention:
+		// When sending we care about the volume of the remote balances,
+		// so we sort by remote balances in descending order.
+		for k, v := range balancesMap {
+			sort.Slice(v, func(i, j int) bool {
+				return v[i].AssetInfo.RemoteBalance >
+					v[j].AssetInfo.RemoteBalance
+			})
+
+			balancesMap[k] = v
+		}
+	case NoIntention:
+		// We don't care about sending or receiving, this means that
+		// the method was called as a dry check. Do nothing.
+	}
+
+	// If a peer public key was specified, we always want to use that to
+	// filter the asset channels.
+	if peerPubKey != nil {
+		_, ok := balancesMap[*peerPubKey]
+		if !ok {
+			return nil, fmt.Errorf("no asset channels found for "+
+				"%s and peer=%s", &specifier, peerPubKey)
+		}
+
+		filteredRes := make(PeerChanMap)
+		filteredRes[*peerPubKey] = balancesMap[*peerPubKey]
+
+		balancesMap = filteredRes
+	}
+
+	return balancesMap, nil
+}
+
+// InboundPolicyFetcher is a helper that fetches the inbound policy of a channel
+// based on its chanID.
+type InboundPolicyFetcher func(ctx context.Context, chanID uint64,
+	remotePubStr string) (*lnrpc.RoutingPolicy, error)
+
+// RfqToHopHint creates the hop hint representation which encapsulates certain
+// quote information along with some other data required by the payment to
+// succeed.
+// Depending on whether the hold flag is set, we either return the lnrpc version
+// of a hop hint or the zpay32 version. This is because we use the lndclient
+// wrapper for hold invoices while we use the raw lnrpc endpoint for simple
+// invoices.
+func (m *Manager) RfqToHopHint(ctx context.Context,
+	policyFetcher InboundPolicyFetcher, channelID uint64,
+	peerPubKey route.Vertex, quote *rfqrpc.PeerAcceptedBuyQuote,
+	hold bool) (*zpay32.HopHint, error) {
+
+	inboundPolicy, err := policyFetcher(ctx, channelID, peerPubKey.String())
+	if err != nil {
+		return nil, fmt.Errorf("unable to get inbound channel "+
+			"policy for channel with ID %d: %w", channelID, err)
+	}
+
+	peerPub, err := btcec.ParsePubKey(peerPubKey[:])
+	if err != nil {
+		return nil, fmt.Errorf("error parsing peer "+
+			"pubkey: %w", err)
+	}
+	hopHint := &zpay32.HopHint{
+		NodeID:      peerPub,
+		ChannelID:   quote.Scid,
+		FeeBaseMSat: uint32(inboundPolicy.FeeBaseMsat),
+		FeeProportionalMillionths: uint32(
+			inboundPolicy.FeeRateMilliMsat,
+		),
+		CLTVExpiryDelta: uint16(
+			inboundPolicy.TimeLockDelta,
+		),
+	}
+
+	return hopHint, nil
+}
+
+// Zpay32HopHintToLnrpc converts a zpay32 hop hint to the lnrpc representation.
+func Zpay32HopHintToLnrpc(h *zpay32.HopHint) *lnrpc.HopHint {
+	return &lnrpc.HopHint{
+		NodeId: fmt.Sprintf(
+			"%x", h.NodeID.SerializeCompressed(),
+		),
+		ChanId:                    h.ChannelID,
+		FeeBaseMsat:               h.FeeBaseMSat,
+		FeeProportionalMillionths: h.FeeProportionalMillionths,
+		CltvExpiryDelta:           uint32(h.CLTVExpiryDelta),
+	}
+}
+
 // publishSubscriberEvent publishes an event to all subscribers.
 func (m *Manager) publishSubscriberEvent(event fn.Event) {
 	// Iterate over the subscribers and deliver the event to each one.
diff --git a/rpcserver.go b/rpcserver.go
index 44985de1..6de277fd 100644
--- a/rpcserver.go
+++ b/rpcserver.go
@@ -6,12 +6,12 @@ import (
 	"crypto/sha256"
 	"encoding/base64"
 	"encoding/hex"
-	"encoding/json"
 	"errors"
 	"fmt"
 	"io"
 	"math"
 	"net/http"
+	"sort"
 	"strings"
 	"sync"
 	"sync/atomic"
@@ -48,6 +48,7 @@ import (
 	"github.com/lightninglabs/taproot-assets/taprpc"
 	wrpc "github.com/lightninglabs/taproot-assets/taprpc/assetwalletrpc"
 	"github.com/lightninglabs/taproot-assets/taprpc/mintrpc"
+	"github.com/lightninglabs/taproot-assets/taprpc/priceoraclerpc"
 	"github.com/lightninglabs/taproot-assets/taprpc/rfqrpc"
 	tchrpc "github.com/lightninglabs/taproot-assets/taprpc/tapchannelrpc"
 	"github.com/lightninglabs/taproot-assets/taprpc/tapdevrpc"
@@ -103,6 +104,10 @@ const (
 	// proofTypeReceive is an alias for the proof type used for receiving
 	// assets.
 	proofTypeReceive = tapdevrpc.ProofTransferType_PROOF_TRANSFER_TYPE_RECEIVE
+
+	// maxRfqHopHints is the maximum number of RFQ quotes that may be
+	// encoded as hop hints in a bolt11 invoice.
+	maxRfqHopHints = 20
 )
 
 type (
@@ -6929,7 +6934,9 @@ func (r *rpcServer) checkPeerChannel(ctx context.Context, peer route.Vertex,
 		// If we don't get an error here, it means we do have an asset
 		// channel with the peer. The intention doesn't matter as we're
 		// just checking whether a channel exists.
-		_, err := r.rfqChannel(ctx, specifier, &peer, NoIntention)
+		_, err := r.cfg.RfqManager.FetchChannel(
+			ctx, specifier, &peer, rfq.NoIntention,
+		)
 		if err != nil {
 			return fmt.Errorf("error checking asset channel: %w",
 				err)
@@ -7634,18 +7641,31 @@ func (r *rpcServer) SendPayment(req *tchrpc.SendPaymentRequest,
 		rpcSpecifier := marshalAssetSpecifier(specifier)
 
 		// We can now query the asset channels we have.
-		assetChan, err := r.rfqChannel(
-			ctx, specifier, peerPubKey, SendIntention,
+		chanMap, err := r.cfg.RfqManager.FetchChannel(
+			ctx, specifier, peerPubKey, rfq.SendIntention,
 		)
 		if err != nil {
 			return fmt.Errorf("error finding asset channel to "+
 				"use: %w", err)
 		}
 
+		// TODO(george): temporary as multi-rfq send is not supported
+		// yet
+		if peerPubKey == nil && len(chanMap) > 1 {
+			return fmt.Errorf("multiple valid peers found, need " +
+				"specify peer pub key")
+		}
+
 		// Even if the user didn't specify the peer public key before,
 		// we definitely know it now. So let's make sure it's always
 		// set.
-		peerPubKey = &assetChan.channelInfo.PubKeyBytes
+		//
+		// TODO(george): we just grab the first value, this is temporary
+		// until multi-rfq send is implemented.
+		for _, v := range chanMap {
+			peerPubKey = &v[0].ChannelInfo.PubKeyBytes
+			break
+		}
 
 		// paymentMaxAmt is the maximum amount that the counterparty is
 		// expected to pay. This is the amount that the invoice is
@@ -7756,7 +7776,9 @@ func (r *rpcServer) SendPayment(req *tchrpc.SendPaymentRequest,
 
 		// We check that we have the asset amount available in the
 		// channel.
-		_, err = r.rfqChannel(ctx, specifier, &dest, SendIntention)
+		_, err = r.cfg.RfqManager.FetchChannel(
+			ctx, specifier, &dest, rfq.SendIntention,
+		)
 		if err != nil {
 			return fmt.Errorf("error finding asset channel to "+
 				"use: %w", err)
@@ -7952,6 +7974,15 @@ func (r *rpcServer) AddInvoice(ctx context.Context,
 		return nil, fmt.Errorf("invoice request must be specified")
 	}
 	iReq := req.InvoiceRequest
+	existingQuotes := iReq.RouteHints != nil
+
+	if existingQuotes && !tapchannel.IsAssetInvoice(
+		iReq, r.cfg.AuxInvoiceManager,
+	) {
+
+		return nil, fmt.Errorf("existing route hints should only " +
+			"contain valid accepted quotes")
+	}
 
 	assetID, groupKey, err := parseAssetSpecifier(
 		req.AssetId, "", req.GroupKey, "",
@@ -7979,18 +8010,14 @@ func (r *rpcServer) AddInvoice(ctx context.Context,
 	}
 
 	// We can now query the asset channels we have.
-	assetChan, err := r.rfqChannel(
-		ctx, specifier, peerPubKey, ReceiveIntention,
+	chanMap, err := r.cfg.RfqManager.FetchChannel(
+		ctx, specifier, peerPubKey, rfq.ReceiveIntention,
 	)
 	if err != nil {
 		return nil, fmt.Errorf("error finding asset channel to use: %w",
 			err)
 	}
 
-	// Even if the user didn't specify the peer public key before, we
-	// definitely know it now. So let's make sure it's always set.
-	peerPubKey = &assetChan.channelInfo.PubKeyBytes
-
 	expirySeconds := iReq.Expiry
 	if expirySeconds == 0 {
 		expirySeconds = int64(rfq.DefaultInvoiceExpiry.Seconds())
@@ -8014,43 +8041,86 @@ func (r *rpcServer) AddInvoice(ctx context.Context,
 
 	rpcSpecifier := marshalAssetSpecifier(specifier)
 
-	resp, err := r.AddAssetBuyOrder(ctx, &rfqrpc.AddAssetBuyOrderRequest{
-		AssetSpecifier: &rpcSpecifier,
-		AssetMaxAmt:    maxUnits,
-		Expiry:         uint64(expiryTimestamp.Unix()),
-		PeerPubKey:     peerPubKey[:],
-		TimeoutSeconds: uint32(
-			rfq.DefaultTimeout.Seconds(),
-		),
-	})
-	if err != nil {
-		return nil, fmt.Errorf("error adding buy order: %w", err)
+	type quoteWithInfo struct {
+		quote   *rfqrpc.PeerAcceptedBuyQuote
+		rate    *rfqmath.BigIntFixedPoint
+		channel rfq.TapChannel
 	}
 
-	var acceptedQuote *rfqrpc.PeerAcceptedBuyQuote
-	switch r := resp.Response.(type) {
-	case *rfqrpc.AddAssetBuyOrderResponse_AcceptedQuote:
-		acceptedQuote = r.AcceptedQuote
+	var acquiredQuotes []quoteWithInfo
 
-	case *rfqrpc.AddAssetBuyOrderResponse_InvalidQuote:
-		return nil, fmt.Errorf("peer %v sent back an invalid quote, "+
-			"status: %v", r.InvalidQuote.Peer,
-			r.InvalidQuote.Status.String())
+	for peer, channels := range chanMap {
+		if existingQuotes {
+			break
+		}
 
-	case *rfqrpc.AddAssetBuyOrderResponse_RejectedQuote:
-		return nil, fmt.Errorf("peer %v rejected the quote, code: %v, "+
-			"error message: %v", r.RejectedQuote.Peer,
-			r.RejectedQuote.ErrorCode, r.RejectedQuote.ErrorMessage)
+		quote, err := r.acquireBuyOrder(
+			ctx, &rpcSpecifier, maxUnits, expiryTimestamp,
+			&peer,
+		)
+		if err != nil {
+			rpcsLog.Errorf("error while trying to acquire a buy "+
+				"order for invoice: %v", err)
+			continue
+		}
 
-	default:
-		return nil, fmt.Errorf("unexpected response type: %T", r)
+		rate, err := rpcutils.UnmarshalFixedPoint(
+			&priceoraclerpc.FixedPoint{
+				Coefficient: quote.AskAssetRate.Coefficient,
+				Scale:       quote.AskAssetRate.Scale,
+			},
+		)
+		if err != nil {
+			return nil, err
+		}
+
+		acquiredQuotes = append(acquiredQuotes, quoteWithInfo{
+			quote: quote,
+			rate:  rate,
+			// Since the channels are sorted, we know the value with
+			// the greatest remote balance is at index 0.
+			channel: channels[0],
+		})
 	}
 
+	// Let's sort the ask rate of the quotes in ascending order.
+	sort.Slice(acquiredQuotes, func(i, j int) bool {
+		return acquiredQuotes[i].rate.ToUint64() <
+			acquiredQuotes[j].rate.ToUint64()
+	})
+
+	// If we failed to get any quotes, we need to return an error. If the
+	// user has already defined quotes in the request we don't return an
+	// error.
+	if len(acquiredQuotes) == 0 && !existingQuotes {
+		return nil, fmt.Errorf("could not create any quotes for the " +
+			"invoice")
+	}
+
+	// We need to trim any extra quotes that cannot make it into the bolt11
+	// invoice due to size limitations.
+	if len(acquiredQuotes) > maxRfqHopHints {
+		acquiredQuotes = acquiredQuotes[:maxRfqHopHints]
+	}
+
+	// TODO(george): We want to cancel back quotes that didn't make it into
+	// the set. Need to add CancelOrder endpoints to RFQ manager.
+
+	// We grab the most expensive rate to use as reference for the total
+	// invoice amount. Since peers have varying prices for the assets, we
+	// pick the most expensive rate in order to allow for any combination of
+	// MPP shards through our set of chosen peers.
+	var expensiveQuote *rfqrpc.PeerAcceptedBuyQuote
+	if !existingQuotes {
+		expensiveQuote = acquiredQuotes[0].quote
+	}
+
+	// replace with above
 	// Now that we have the accepted quote, we know the amount in (milli)
 	// Satoshi that we need to pay. We can now update the invoice with this
 	// amount.
 	invoiceAmtMsat, err := validateInvoiceAmount(
-		acceptedQuote, req.AssetAmount, iReq,
+		expensiveQuote, req.AssetAmount, iReq,
 	)
 	if err != nil {
 		return nil, fmt.Errorf("error validating invoice amount: %w",
@@ -8058,19 +8128,6 @@ func (r *rpcServer) AddInvoice(ctx context.Context,
 	}
 	iReq.ValueMsat = int64(invoiceAmtMsat)
 
-	// The last step is to create a hop hint that includes the fake SCID of
-	// the quote, alongside the channel's routing policy. We need to choose
-	// the policy that points towards us, as the payment will be flowing in.
-	// So we get the policy that's being set by the remote peer.
-	channelID := assetChan.channelInfo.ChannelID
-	inboundPolicy, err := r.getInboundPolicy(
-		ctx, channelID, peerPubKey.String(),
-	)
-	if err != nil {
-		return nil, fmt.Errorf("unable to get inbound channel policy "+
-			"for channel with ID %d: %w", channelID, err)
-	}
-
 	// If this is a hodl invoice, then we'll copy over the relevant fields,
 	// then route this through the invoicerpc instead.
 	if req.HodlInvoice != nil {
@@ -8080,24 +8137,21 @@ func (r *rpcServer) AddInvoice(ctx context.Context,
 				"hash: %w", err)
 		}
 
-		peerPub, err := btcec.ParsePubKey(peerPubKey[:])
-		if err != nil {
-			return nil, fmt.Errorf("error parsing peer "+
-				"pubkey: %w", err)
-		}
+		routeHints := make([][]zpay32.HopHint, 0)
+		for _, v := range acquiredQuotes {
+			hopHint, err := r.cfg.RfqManager.RfqToHopHint(
+				ctx, r.getInboundPolicy,
+				v.channel.ChannelInfo.ChannelID,
+				v.channel.ChannelInfo.PubKeyBytes, v.quote,
+				true,
+			)
+			if err != nil {
+				return nil, err
+			}
 
-		hopHint := []zpay32.HopHint{
-			{
-				NodeID:      peerPub,
-				ChannelID:   acceptedQuote.Scid,
-				FeeBaseMSat: uint32(inboundPolicy.FeeBaseMsat),
-				FeeProportionalMillionths: uint32(
-					inboundPolicy.FeeRateMilliMsat,
-				),
-				CLTVExpiryDelta: uint16(
-					inboundPolicy.TimeLockDelta,
-				),
-			},
+			routeHints = append(
+				routeHints, []zpay32.HopHint{*hopHint},
+			)
 		}
 
 		payReq, err := r.cfg.Lnd.Invoices.AddHoldInvoice(
@@ -8113,7 +8167,7 @@ func (r *rpcServer) AddInvoice(ctx context.Context,
 				// add any hop hints other than this one.
 				Private:     false,
 				HodlInvoice: true,
-				RouteHints:  [][]zpay32.HopHint{hopHint},
+				RouteHints:  routeHints,
 			},
 		)
 		if err != nil {
@@ -8122,29 +8176,39 @@ func (r *rpcServer) AddInvoice(ctx context.Context,
 		}
 
 		return &tchrpc.AddInvoiceResponse{
-			AcceptedBuyQuote: acceptedQuote,
+			// TODO(george): For now we just return the expensive
+			// quote
+			AcceptedBuyQuote: expensiveQuote,
 			InvoiceResult: &lnrpc.AddInvoiceResponse{
 				PaymentRequest: payReq,
 			},
 		}, nil
 	}
 
-	// Otherwise, we'll make this into a normal invoice.
-	hopHint := &lnrpc.HopHint{
-		NodeId:      peerPubKey.String(),
-		ChanId:      acceptedQuote.Scid,
-		FeeBaseMsat: uint32(inboundPolicy.FeeBaseMsat),
-		FeeProportionalMillionths: uint32(
-			inboundPolicy.FeeRateMilliMsat,
-		),
-		CltvExpiryDelta: inboundPolicy.TimeLockDelta,
-	}
-	iReq.RouteHints = []*lnrpc.RouteHint{
-		{
+	routeHints := make([]*lnrpc.RouteHint, 0)
+	for _, v := range acquiredQuotes {
+		hopHint, err := r.cfg.RfqManager.RfqToHopHint(
+			ctx, r.getInboundPolicy,
+			v.channel.ChannelInfo.ChannelID,
+			v.channel.ChannelInfo.PubKeyBytes, v.quote, false,
+		)
+		if err != nil {
+			return nil, err
+		}
+
+		lnrpcHopHint := rfq.Zpay32HopHintToLnrpc(hopHint)
+
+		routeHints = append(routeHints, &lnrpc.RouteHint{
 			HopHints: []*lnrpc.HopHint{
-				hopHint,
+				lnrpcHopHint,
 			},
-		},
+		})
+	}
+
+	// Only replace the route hints of the invoice request if the user has
+	// not already set them.
+	if !existingQuotes {
+		iReq.RouteHints = routeHints
 	}
 
 	rpcCtx, _, rawClient := r.cfg.Lnd.Client.RawClientWithMacAuth(ctx)
@@ -8154,7 +8218,7 @@ func (r *rpcServer) AddInvoice(ctx context.Context,
 	}
 
 	return &tchrpc.AddInvoiceResponse{
-		AcceptedBuyQuote: acceptedQuote,
+		AcceptedBuyQuote: expensiveQuote,
 		InvoiceResult:    invoiceResp,
 	}, nil
 }
@@ -8300,6 +8364,57 @@ func validateInvoiceAmount(acceptedQuote *rfqrpc.PeerAcceptedBuyQuote,
 	return newInvoiceAmtMsat, nil
 }
 
+// acquireBuyOrder performs an RFQ negotiation with the target peer and quote
+// parameters and returns the quote if the negotiation was successful.
+func (r *rpcServer) acquireBuyOrder(ctx context.Context,
+	rpcSpecifier *rfqrpc.AssetSpecifier, assetMaxAmt uint64,
+	expiryTimestamp time.Time,
+	peerPubKey *route.Vertex) (*rfqrpc.PeerAcceptedBuyQuote, error) {
+
+	var quote *rfqrpc.PeerAcceptedBuyQuote
+
+	resp, err := r.AddAssetBuyOrder(ctx, &rfqrpc.AddAssetBuyOrderRequest{
+		AssetSpecifier: rpcSpecifier,
+		AssetMaxAmt:    assetMaxAmt,
+		Expiry:         uint64(expiryTimestamp.Unix()),
+		PeerPubKey:     peerPubKey[:],
+		TimeoutSeconds: uint32(
+			rfq.DefaultTimeout.Seconds(),
+		),
+	})
+	if err != nil {
+		return quote, fmt.Errorf("error adding buy order: %w", err)
+	}
+
+	switch r := resp.Response.(type) {
+	case *rfqrpc.AddAssetBuyOrderResponse_AcceptedQuote:
+		quote = r.AcceptedQuote
+
+	case *rfqrpc.AddAssetBuyOrderResponse_InvalidQuote:
+		return nil, fmt.Errorf("peer %v sent back an invalid quote, "+
+			"status: %v", r.InvalidQuote.Peer,
+			r.InvalidQuote.Status.String())
+
+	case *rfqrpc.AddAssetBuyOrderResponse_RejectedQuote:
+		return nil, fmt.Errorf("peer %v rejected the quote, code: %v, "+
+			"error message: %v", r.RejectedQuote.Peer,
+			r.RejectedQuote.ErrorCode, r.RejectedQuote.ErrorMessage)
+
+	default:
+		return nil, fmt.Errorf("unexpected response type: %T", r)
+	}
+
+	if quote.MinTransportableUnits > assetMaxAmt {
+		return nil, fmt.Errorf("cannot create invoice over %d asset "+
+			"units, as the minimal transportable amount is %d "+
+			"units with the current rate of %v units/BTC",
+			assetMaxAmt, quote.MinTransportableUnits,
+			quote.AskAssetRate)
+	}
+
+	return quote, nil
+}
+
 // DeclareScriptKey declares a new script key to the wallet. This is useful
 // when the script key contains scripts, which would mean it wouldn't be
 // recognized by the wallet automatically. Declaring a script key will make any
@@ -8395,196 +8510,6 @@ func (r *rpcServer) DecDisplayForAssetID(ctx context.Context,
 	return meta.DecDisplayOption()
 }
 
-// chanIntention defines the intention of calling rfqChannel. This helps with
-// returning the channel that is most suitable for what we want to do.
-type chanIntention uint8
-
-const (
-	// NoIntention defines the absence of any intention, signalling that we
-	// don't really care which channel is returned.
-	NoIntention chanIntention = iota
-
-	// SendIntention defines the intention to send over an asset channel.
-	SendIntention
-
-	// ReceiveIntention defines the intention to receive over an asset
-	// channel.
-	ReceiveIntention
-)
-
-// rfqChannel returns the channel to use for RFQ operations. If a peer public
-// key is specified, the channels are filtered by that peer. If there are
-// multiple channels for the same specifier, the user must specify the peer
-// public key.
-func (r *rpcServer) rfqChannel(ctx context.Context, specifier asset.Specifier,
-	peerPubKey *route.Vertex,
-	intention chanIntention) (*channelWithSpecifier, error) {
-
-	balances, haveGroupChans, err := r.computeCompatibleChannelAssetBalance(
-		ctx, specifier,
-	)
-	if err != nil {
-		return nil, fmt.Errorf("error computing available asset "+
-			"channel balance: %w", err)
-	}
-
-	// If the user uses the asset ID to specify what asset to use, that will
-	// not work for asset channels that have multiple UTXOs of grouped
-	// assets. The result wouldn't be deterministic anyway (meaning, it's
-	// not guaranteed that a specific asset ID is moved within a channel
-	// when an HTLC is sent, the allocation logic decides which actual UTXO
-	// is used). So we tell the user to use the group key instead, at least
-	// for channels that have multiple UTXOs of grouped assets.
-	if specifier.HasId() && len(balances) == 0 && haveGroupChans {
-		return nil, fmt.Errorf("no compatible asset channel found for "+
-			"%s, make sure to use group key for grouped asset "+
-			"channels", &specifier)
-	}
-
-	// If the above special case didn't apply, it just means we don't have
-	// the specific asset in a channel that can be used.
-	if len(balances) == 0 {
-		return nil, fmt.Errorf("no asset channel balance found for %s",
-			&specifier)
-	}
-
-	// If a peer public key was specified, we always want to use that to
-	// filter the asset channels.
-	if peerPubKey != nil {
-		balances = fn.Filter(
-			balances, func(c channelWithSpecifier) bool {
-				return c.channelInfo.PubKeyBytes == *peerPubKey
-			},
-		)
-	}
-
-	switch {
-	// If there are multiple asset channels for the same specifier, we need
-	// to ask the user to specify the peer public key. Otherwise, we don't
-	// know who to ask for a quote.
-	case len(balances) > 1 && peerPubKey == nil:
-		return nil, fmt.Errorf("multiple asset channels found for "+
-			"%s, please specify the peer pubkey", &specifier)
-
-	// We don't have any channels with that asset ID and peer.
-	case len(balances) == 0:
-		return nil, fmt.Errorf("no asset channel found for %s",
-			&specifier)
-	}
-
-	// If the user specified a peer public key, and we still have multiple
-	// channels, it means we have multiple channels with the same asset and
-	// the same peer, as we ruled out the rest of the cases above.
-
-	// Initialize best balance to first channel of the list.
-	bestBalance := balances[0]
-
-	switch intention {
-	case ReceiveIntention:
-		// If the intention is to receive, return the channel
-		// with the best remote balance.
-		fn.ForEach(balances, func(b channelWithSpecifier) {
-			if b.assetInfo.RemoteBalance >
-				bestBalance.assetInfo.RemoteBalance {
-
-				bestBalance = b
-			}
-		})
-
-	case SendIntention:
-		// If the intention is to send, return the channel with
-		// the best local balance.
-		fn.ForEach(balances, func(b channelWithSpecifier) {
-			if b.assetInfo.LocalBalance >
-				bestBalance.assetInfo.LocalBalance {
-
-				bestBalance = b
-			}
-		})
-
-	case NoIntention:
-		// Do nothing. Just return the first element that was
-		// assigned above.
-	}
-
-	return &bestBalance, nil
-}
-
-// channelWithSpecifier is a helper struct that combines the information of an
-// asset specifier that is satisfied by a channel with the channels' general
-// information.
-type channelWithSpecifier struct {
-	// specifier is the asset specifier that is satisfied by this channels'
-	// assets.
-	specifier asset.Specifier
-
-	// channelInfo is the information about the channel the asset is
-	// committed to.
-	channelInfo lndclient.ChannelInfo
-
-	// assetInfo contains the asset related info of the channel.
-	assetInfo rfqmsg.JsonAssetChannel
-}
-
-// computeCompatibleChannelAssetBalance computes the total local and remote
-// balance for each asset channel that matches the provided asset specifier.
-// For a channel to match an asset specifier, all asset UTXOs in the channel
-// must match the specifier. That means a channel is seen as _not_ compatible if
-// it contains multiple asset IDs but the specifier only specifies one of them.
-// The user should use the group key in that case instead. To help inform the
-// user about this, the returned boolean value indicates if there are any active
-// channels that have grouped assets.
-func (r *rpcServer) computeCompatibleChannelAssetBalance(ctx context.Context,
-	specifier asset.Specifier) ([]channelWithSpecifier, bool, error) {
-
-	activeChannels, err := r.cfg.Lnd.Client.ListChannels(ctx, true, false)
-	if err != nil {
-		return nil, false, fmt.Errorf("unable to fetch channels: %w",
-			err)
-	}
-
-	var (
-		channels                 = make([]channelWithSpecifier, 0)
-		haveGroupedAssetChannels bool
-	)
-	for chanIdx := range activeChannels {
-		openChan := activeChannels[chanIdx]
-		if len(openChan.CustomChannelData) == 0 {
-			continue
-		}
-
-		var assetData rfqmsg.JsonAssetChannel
-		err = json.Unmarshal(openChan.CustomChannelData, &assetData)
-		if err != nil {
-			return nil, false, fmt.Errorf("unable to unmarshal "+
-				"asset data: %w", err)
-		}
-
-		if len(assetData.GroupKey) > 0 {
-			haveGroupedAssetChannels = true
-		}
-
-		// Check if the assets of this channel match the provided
-		// specifier.
-		pass, err := r.cfg.RfqManager.ChannelMatchesFully(
-			ctx, assetData, specifier,
-		)
-		if err != nil {
-			return nil, false, err
-		}
-
-		if pass {
-			channels = append(channels, channelWithSpecifier{
-				specifier:   specifier,
-				channelInfo: openChan,
-				assetInfo:   assetData,
-			})
-		}
-	}
-
-	return channels, haveGroupedAssetChannels, nil
-}
-
 // getInboundPolicy returns the policy of the given channel that points towards
 // our node, so it's the policy set by the remote peer.
 func (r *rpcServer) getInboundPolicy(ctx context.Context, chanID uint64,
diff --git a/tapcfg/server.go b/tapcfg/server.go
index b2f5449a..ae7e343e 100644
--- a/tapcfg/server.go
+++ b/tapcfg/server.go
@@ -397,7 +397,7 @@ func genServerConfig(cfg *Config, cfgLogger btclog.Logger,
 			HtlcInterceptor: lndRouterClient,
 			HtlcSubscriber:  lndRouterClient,
 			PriceOracle:     priceOracle,
-			ChannelLister:   walletAnchor,
+			ChannelLister:   lndServices.Client,
 			GroupLookup:     tapdbAddrBook,
 			AliasManager:    lndRouterClient,
 			// nolint: lll
diff --git a/tapchannel/aux_invoice_manager.go b/tapchannel/aux_invoice_manager.go
index f18cfe55..a3045acb 100644
--- a/tapchannel/aux_invoice_manager.go
+++ b/tapchannel/aux_invoice_manager.go
@@ -190,7 +190,7 @@ func (s *AuxInvoiceManager) handleInvoiceAccept(ctx context.Context,
 		// accepting sats instead of assets.
 		//
 		// TODO(george): Strict-forwarding could be configurable?
-		if isAssetInvoice(req.Invoice, s) {
+		if IsAssetInvoice(req.Invoice, s) {
 			iLog.Debugf("has no asset custom records, but " +
 				"invoice requires assets, canceling HTLCs")
 
@@ -202,7 +202,7 @@ func (s *AuxInvoiceManager) handleInvoiceAccept(ctx context.Context,
 		return resp, nil
 
 	// We have custom records, but the invoice is not an asset invoice.
-	case !isAssetInvoice(req.Invoice, s) && !req.Invoice.IsKeysend:
+	case !IsAssetInvoice(req.Invoice, s) && !req.Invoice.IsKeysend:
 		// If we do have custom records, but the invoice does not
 		// correspond to an asset invoice, we do not settle the invoice.
 		// Since we requested btc we should be receiving btc.
@@ -386,11 +386,11 @@ func (s *AuxInvoiceManager) RfqPeerFromScid(scid uint64) (route.Vertex, error) {
 	return buyQuote.Peer, nil
 }
 
-// isAssetInvoice checks whether the provided invoice is an asset invoice. This
+// IsAssetInvoice checks whether the provided invoice is an asset invoice. This
 // method checks whether the routing hints of the invoice match those created
 // when generating an asset invoice, and if that's the case we then check that
 // the scid matches an existing quote.
-func isAssetInvoice(invoice *lnrpc.Invoice, rfqLookup RfqLookup) bool {
+func IsAssetInvoice(invoice *lnrpc.Invoice, rfqLookup RfqLookup) bool {
 	hints := invoice.RouteHints
 
 	for _, hint := range hints {
diff --git a/taprpc/tapchannelrpc/tapchannel.proto b/taprpc/tapchannelrpc/tapchannel.proto
index d665afa4..77afd489 100644
--- a/taprpc/tapchannelrpc/tapchannel.proto
+++ b/taprpc/tapchannelrpc/tapchannel.proto
@@ -37,7 +37,9 @@ service TaprootAssetChannels {
     /* litcli: `ln addinvoice`
     AddInvoice is a wrapper around lnd's lnrpc.AddInvoice method with asset
     specific parameters. It allows RPC users to create invoices that correspond
-    to the specified asset amount.
+    to the specified asset amount. If a peer pubkey is specified, then only that
+    peer will be used for RFQ negotiations. If none is specified then RFQ quotes
+    for all peers with suitable asset channels will be created.
     */
     rpc AddInvoice (AddInvoiceRequest) returns (AddInvoiceResponse);
 
diff --git a/taprpc/tapchannelrpc/tapchannel.swagger.json b/taprpc/tapchannelrpc/tapchannel.swagger.json
index fb5f4ed5..bc5d5a05 100644
--- a/taprpc/tapchannelrpc/tapchannel.swagger.json
+++ b/taprpc/tapchannelrpc/tapchannel.swagger.json
@@ -84,7 +84,7 @@
     },
     "/v1/taproot-assets/channels/invoice": {
       "post": {
-        "summary": "litcli: `ln addinvoice`\nAddInvoice is a wrapper around lnd's lnrpc.AddInvoice method with asset\nspecific parameters. It allows RPC users to create invoices that correspond\nto the specified asset amount.",
+        "summary": "litcli: `ln addinvoice`\nAddInvoice is a wrapper around lnd's lnrpc.AddInvoice method with asset\nspecific parameters. It allows RPC users to create invoices that correspond\nto the specified asset amount. If a peer pubkey is specified, then only that\npeer will be used for RFQ negotiations. If none is specified then RFQ quotes\nfor all peers with suitable asset channels will be created.",
         "operationId": "TaprootAssetChannels_AddInvoice",
         "responses": {
           "200": {
diff --git a/taprpc/tapchannelrpc/tapchannel_grpc.pb.go b/taprpc/tapchannelrpc/tapchannel_grpc.pb.go
index cffa39ab..4d1302d8 100644
--- a/taprpc/tapchannelrpc/tapchannel_grpc.pb.go
+++ b/taprpc/tapchannelrpc/tapchannel_grpc.pb.go
@@ -37,7 +37,9 @@ type TaprootAssetChannelsClient interface {
 	// litcli: `ln addinvoice`
 	// AddInvoice is a wrapper around lnd's lnrpc.AddInvoice method with asset
 	// specific parameters. It allows RPC users to create invoices that correspond
-	// to the specified asset amount.
+	// to the specified asset amount. If a peer pubkey is specified, then only that
+	// peer will be used for RFQ negotiations. If none is specified then RFQ quotes
+	// for all peers with suitable asset channels will be created.
 	AddInvoice(ctx context.Context, in *AddInvoiceRequest, opts ...grpc.CallOption) (*AddInvoiceResponse, error)
 	// litcli: `ln decodeassetinvoice`
 	// DecodeAssetPayReq is similar to lnd's lnrpc.DecodePayReq, but it accepts an
@@ -145,7 +147,9 @@ type TaprootAssetChannelsServer interface {
 	// litcli: `ln addinvoice`
 	// AddInvoice is a wrapper around lnd's lnrpc.AddInvoice method with asset
 	// specific parameters. It allows RPC users to create invoices that correspond
-	// to the specified asset amount.
+	// to the specified asset amount. If a peer pubkey is specified, then only that
+	// peer will be used for RFQ negotiations. If none is specified then RFQ quotes
+	// for all peers with suitable asset channels will be created.
 	AddInvoice(context.Context, *AddInvoiceRequest) (*AddInvoiceResponse, error)
 	// litcli: `ln decodeassetinvoice`
 	// DecodeAssetPayReq is similar to lnd's lnrpc.DecodePayReq, but it accepts an
diff --git a/wallet_anchor.go b/wallet_anchor.go
index ed44d8a4..78015102 100644
--- a/wallet_anchor.go
+++ b/wallet_anchor.go
@@ -13,7 +13,6 @@ import (
 	"github.com/btcsuite/btcd/wire"
 	"github.com/btcsuite/btcwallet/waddrmgr"
 	"github.com/lightninglabs/lndclient"
-	"github.com/lightninglabs/taproot-assets/rfq"
 	"github.com/lightninglabs/taproot-assets/tapfreighter"
 	"github.com/lightninglabs/taproot-assets/tapgarden"
 	"github.com/lightninglabs/taproot-assets/tapsend"
@@ -237,4 +236,3 @@ func (l *LndRpcWalletAnchor) MinRelayFee(
 var _ tapgarden.WalletAnchor = (*LndRpcWalletAnchor)(nil)
 
 var _ tapfreighter.WalletAnchor = (*LndRpcWalletAnchor)(nil)
-var _ rfq.ChannelLister = (*LndRpcWalletAnchor)(nil)



Test patch:
diff --git a/rfq/manager_test.go b/rfq/manager_test.go
new file mode 100644
index 00000000..5e0452b0
--- /dev/null
+++ b/rfq/manager_test.go
@@ -0,0 +1,352 @@
+package rfq
+
+import (
+	"context"
+	"encoding/binary"
+	"testing"
+
+	"github.com/btcsuite/btcd/btcec/v2"
+	"github.com/decred/dcrd/dcrec/secp256k1/v4"
+	"github.com/lightninglabs/lndclient"
+	"github.com/lightninglabs/taproot-assets/address"
+	"github.com/lightninglabs/taproot-assets/asset"
+	"github.com/lightninglabs/taproot-assets/proof"
+	tpchmsg "github.com/lightninglabs/taproot-assets/tapchannelmsg"
+	"github.com/lightningnetwork/lnd/lnwallet"
+	"github.com/lightningnetwork/lnd/routing/route"
+	"github.com/stretchr/testify/require"
+)
+
+var (
+	asset1 = asset.Asset{
+		ScriptKey: asset.ScriptKey{
+			PubKey: asset.NUMSPubKey,
+		},
+		Genesis: asset.Genesis{
+			Tag: "111",
+		},
+	}
+	asset2 = asset.Asset{
+		ScriptKey: asset.ScriptKey{
+			PubKey: asset.NUMSPubKey,
+		},
+		Genesis: asset.Genesis{
+			Tag: "222",
+		},
+	}
+	asset3 = asset.Asset{
+		ScriptKey: asset.ScriptKey{
+			PubKey: asset.NUMSPubKey,
+		},
+		Genesis: asset.Genesis{
+			Tag: "333",
+		},
+	}
+	testAssetID1 = asset1.ID()
+	testAssetID2 = asset2.ID()
+	testAssetID3 = asset3.ID()
+	proof1       = proof.Proof{
+		Asset: asset1,
+	}
+	proof2 = proof.Proof{
+		Asset: asset2,
+	}
+	proof3 = proof.Proof{
+		Asset: asset3,
+	}
+	testGroupKey = pubKeyFromUint64(2121)
+	peer1        = route.Vertex{88}
+	peer2        = route.Vertex{77}
+)
+
+// GroupLookupMock mocks the GroupLookup interface that is required by the
+// rfq manager to check asset IDs against asset specifiers.
+type GroupLookupMock struct{}
+
+// QueryAssetGroup fetches the group information of an asset, if it belongs in a
+// group.
+func (g *GroupLookupMock) QueryAssetGroup(_ context.Context,
+	id asset.ID) (*asset.AssetGroup, error) {
+
+	// We only consider testAssetID1 and testAssetID2 to be in the group.
+	if id == testAssetID1 || id == testAssetID2 {
+		return &asset.AssetGroup{
+			GroupKey: &asset.GroupKey{
+				GroupPubKey: *testGroupKey,
+			},
+		}, nil
+	}
+
+	return nil, address.ErrAssetGroupUnknown
+}
+
+// testCaseComputeChannelAssetBalance is a test case for computing the channel
+// asset balances.
+type testCaseComputeChannelAssetBalance struct {
+	name               string
+	activeChannels     []lndclient.ChannelInfo
+	specifier          asset.Specifier
+	expectedValidPeers int
+	expectedLocalBal   uint64
+	expectedRemoteBal  uint64
+}
+
+// createChannelWithCustomData creates a dummy channel with only the custom data
+// and peer fields populated. The custom data encode the local and remote
+// balances of the given asset ID.
+func createChannelWithCustomData(t *testing.T, id asset.ID, localBalance,
+	remoteBalance uint64, proof proof.Proof,
+	peer route.Vertex) lndclient.ChannelInfo {
+
+	customData := tpchmsg.ChannelCustomData{
+		LocalCommit: *tpchmsg.NewCommitment(
+			[]*tpchmsg.AssetOutput{
+				tpchmsg.NewAssetOutput(
+					id, localBalance, proof,
+				),
+			},
+			[]*tpchmsg.AssetOutput{
+				tpchmsg.NewAssetOutput(
+					id, remoteBalance, proof,
+				),
+			},
+			nil, nil, lnwallet.CommitAuxLeaves{},
+		),
+		OpenChan: *tpchmsg.NewOpenChannel(
+			[]*tpchmsg.AssetOutput{
+				tpchmsg.NewAssetOutput(
+					id,
+					localBalance+remoteBalance, proof,
+				),
+			}, 0, nil,
+		),
+	}
+
+	data, err := customData.AsJson()
+	require.NoError(t, err)
+
+	return lndclient.ChannelInfo{
+		CustomChannelData: data,
+		PubKeyBytes:       peer,
+	}
+}
+
+// assertComputeChannelAssetBalance asserts that the manager can compute the
+// correct asset balances for the test case. It also compares the results
+// against some expected values.
+func assertComputeChannelAssetBalance(t *testing.T,
+	tc testCaseComputeChannelAssetBalance) {
+
+	mockGroupLookup := &GroupLookupMock{}
+	cfg := ManagerCfg{
+		GroupLookup: mockGroupLookup,
+	}
+	manager, err := NewManager(cfg)
+	require.NoError(t, err)
+
+	ctxt, cancel := context.WithTimeout(
+		context.Background(), DefaultTimeout,
+	)
+	defer cancel()
+
+	chanMap, _, err := manager.ComputeChannelAssetBalance(
+		ctxt, tc.activeChannels, tc.specifier,
+	)
+	require.NoError(t, err)
+
+	// We avoid using require.Len directly on the map here as it will print
+	// the whole map on fail.
+	require.Equal(t, tc.expectedValidPeers, len(chanMap))
+
+	var totalLocal, totalRemote uint64
+
+	for _, v := range chanMap {
+		for _, ch := range v {
+			totalLocal += ch.AssetInfo.LocalBalance
+			totalRemote += ch.AssetInfo.RemoteBalance
+		}
+	}
+
+	require.Equal(t, tc.expectedLocalBal, totalLocal)
+	require.Equal(t, tc.expectedRemoteBal, totalRemote)
+}
+
+// TestComputeChannelAssetBalance tests that the rfq manager can correctly
+// filter the channels according to the asset ID of the channel and the provided
+// asset specifier.
+func TestComputeChannelAssetBlanace(t *testing.T) {
+	testCases := []testCaseComputeChannelAssetBalance{
+		{
+			name: "1 asset 1 channel 1 peer",
+			activeChannels: []lndclient.ChannelInfo{
+				createChannelWithCustomData(
+					t, testAssetID1, 10_000, 15_000, proof1,
+					peer1,
+				),
+			},
+			specifier: asset.NewSpecifierFromId(
+				testAssetID1,
+			),
+			expectedValidPeers: 1,
+			expectedLocalBal:   10_000,
+			expectedRemoteBal:  15_000,
+		},
+		{
+			name: "1 asset 2 channels 1 peer",
+			activeChannels: []lndclient.ChannelInfo{
+				createChannelWithCustomData(
+					t, testAssetID1, 10_000, 15_000, proof1,
+					peer1,
+				),
+				createChannelWithCustomData(
+					t, testAssetID1, 10_000, 15_000, proof1,
+					peer1,
+				),
+			},
+			specifier: asset.NewSpecifierFromId(
+				testAssetID1,
+			),
+			expectedValidPeers: 1,
+			expectedLocalBal:   20_000,
+			expectedRemoteBal:  30_000,
+		},
+		{
+			name: "1 asset 2 channels 2 peers",
+			activeChannels: []lndclient.ChannelInfo{
+				createChannelWithCustomData(
+					t, testAssetID1, 10_000, 15_000, proof1,
+					peer1,
+				),
+				createChannelWithCustomData(
+					t, testAssetID1, 10_000, 15_000, proof1,
+					peer2,
+				),
+			},
+			specifier: asset.NewSpecifierFromId(
+				testAssetID1,
+			),
+			expectedValidPeers: 2,
+			expectedLocalBal:   20_000,
+			expectedRemoteBal:  30_000,
+		},
+		{
+			name: "2 assets 2 channels 2 peers, asset specifier",
+			activeChannels: []lndclient.ChannelInfo{
+				createChannelWithCustomData(
+					t, testAssetID1, 10_000, 15_000, proof1,
+					peer1,
+				),
+				createChannelWithCustomData(
+					t, testAssetID2, 10_000, 15_000, proof2,
+					peer2,
+				),
+			},
+			specifier: asset.NewSpecifierFromId(
+				testAssetID1,
+			),
+			expectedValidPeers: 1,
+			expectedLocalBal:   10_000,
+			expectedRemoteBal:  15_000,
+		},
+		{
+			name: "2 assets 2 channels 2 peers, group specifier",
+			activeChannels: []lndclient.ChannelInfo{
+				createChannelWithCustomData(
+					t, testAssetID1, 10_000, 15_000, proof1,
+					peer1,
+				),
+				createChannelWithCustomData(
+					t, testAssetID2, 10_000, 15_000, proof2,
+					peer2,
+				),
+			},
+			specifier: asset.NewSpecifierFromGroupKey(
+				*testGroupKey,
+			),
+			expectedValidPeers: 2,
+			expectedLocalBal:   20_000,
+			expectedRemoteBal:  30_000,
+		},
+		{
+			name: "3 assets 3 channels 2 peers, group specifier",
+			activeChannels: []lndclient.ChannelInfo{
+				createChannelWithCustomData(
+					t, testAssetID1, 10_000, 15_000, proof1,
+					peer1,
+				),
+				createChannelWithCustomData(
+					t, testAssetID2, 10_000, 15_000, proof2,
+					peer2,
+				),
+				createChannelWithCustomData(
+					t, testAssetID3, 10_000, 15_000, proof3,
+					peer2,
+				),
+			},
+			specifier: asset.NewSpecifierFromGroupKey(
+				*testGroupKey,
+			),
+			expectedValidPeers: 2,
+			expectedLocalBal:   20_000,
+			expectedRemoteBal:  30_000,
+		},
+		{
+			name: "3 assets 6 channels 2 peers, group specifier",
+			activeChannels: []lndclient.ChannelInfo{
+				createChannelWithCustomData(
+					t, testAssetID1, 10_000, 15_000, proof1,
+					peer1,
+				),
+				createChannelWithCustomData(
+					t, testAssetID2, 10_000, 15_000, proof2,
+					peer1,
+				),
+				createChannelWithCustomData(
+					t, testAssetID3, 10_000, 15_000, proof3,
+					peer1,
+				),
+				createChannelWithCustomData(
+					t, testAssetID1, 10_000, 15_000, proof1,
+					peer2,
+				),
+				createChannelWithCustomData(
+					t, testAssetID2, 10_000, 15_000, proof2,
+					peer2,
+				),
+				createChannelWithCustomData(
+					t, testAssetID3, 10_000, 15_000, proof3,
+					peer2,
+				),
+			},
+			specifier: asset.NewSpecifierFromGroupKey(
+				*testGroupKey,
+			),
+			expectedValidPeers: 2,
+			expectedLocalBal:   40_000,
+			expectedRemoteBal:  60_000,
+		},
+	}
+
+	for idx := range testCases {
+		tc := testCases[idx]
+
+		success := t.Run(tc.name, func(t *testing.T) {
+			assertComputeChannelAssetBalance(t, tc)
+		})
+		if !success {
+			break
+		}
+	}
+}
+
+// pubKeyFromUint64 is a helper function that generates a public key from a
+// uint64 value.
+func pubKeyFromUint64(num uint64) *btcec.PublicKey {
+	var (
+		buf    = make([]byte, 8)
+		scalar = new(secp256k1.ModNScalar)
+	)
+	binary.BigEndian.PutUint64(buf, num)
+	_ = scalar.SetByteSlice(buf)
+	return secp256k1.NewPrivateKey(scalar).PubKey()
+}
diff --git a/tapchannel/aux_invoice_manager_test.go b/tapchannel/aux_invoice_manager_test.go
index 415f31cc..310101da 100644
--- a/tapchannel/aux_invoice_manager_test.go
+++ b/tapchannel/aux_invoice_manager_test.go
@@ -226,7 +226,7 @@ func (m *mockHtlcModifierProperty) HtlcModifier(ctx context.Context,
 		}
 
 		if !rfqmsg.HasAssetHTLCCustomRecords(r.WireCustomRecords) {
-			if isAssetInvoice(r.Invoice, m) {
+			if IsAssetInvoice(r.Invoice, m) {
 				if !res.CancelSet {
 					m.t.Errorf("expected cancel set flag")
 				}
@@ -236,7 +236,7 @@ func (m *mockHtlcModifierProperty) HtlcModifier(ctx context.Context,
 			if r.ExitHtlcAmt != res.AmtPaid {
 				m.t.Errorf("AmtPaid != ExitHtlcAmt")
 			}
-		} else if !isAssetInvoice(r.Invoice, m) {
+		} else if !IsAssetInvoice(r.Invoice, m) {
 			if !res.CancelSet {
 				m.t.Errorf("expected cancel set flag")
 			}