loopout: add asset loop out payment flow

This commit is contained in:
sputn1ck 2025-01-09 16:29:05 +01:00
parent 0a0cda46b9
commit 1440b6bb1d
No known key found for this signature in database
GPG key ID: 671103D881A5F0E4
6 changed files with 317 additions and 14 deletions

106
client.go
View file

@ -79,6 +79,13 @@ var (
// quote call as the miner fee if the fee estimation in lnd's wallet
// failed because of insufficient funds.
MinerFeeEstimationFailed btcutil.Amount = -1
// defaultRFQExpiry is the default expiry time for RFQs.
defaultRFQExpiry = 5 * time.Minute
// defaultRFQMaxLimitMultiplier is the default maximum fee multiplier for
// RFQs.
defaultRFQMaxLimitMultiplier = 1.2
)
// Client performs the client side part of swaps. This interface exists to be
@ -506,9 +513,30 @@ func (s *Client) resumeSwaps(ctx context.Context,
func (s *Client) LoopOut(globalCtx context.Context,
request *OutRequest) (*LoopOutSwapInfo, error) {
log.Infof("LoopOut %v to %v (channels: %v)",
request.Amount, request.DestAddr, request.OutgoingChanSet,
)
if request.AssetId != nil {
if request.AssetPrepayRfqId == nil ||
request.AssetSwapRfqId == nil {
return nil, errors.New("asset prepay and swap rfq ids " +
"must be set when using an asset id")
}
// Verify that if we have an asset id set, we have a valid asset
// client to use.
if s.assetClient == nil {
return nil, errors.New("asset client must be set " +
"when using an asset id")
}
log.Infof("LoopOut %v sats to %v with asset %x",
request.Amount, request.DestAddr, request.AssetId,
)
} else {
log.Infof("LoopOut %v to %v (channels: %v)",
request.Amount, request.DestAddr,
request.OutgoingChanSet,
)
}
if err := s.waitForInitialized(globalCtx); err != nil {
return nil, err
@ -529,7 +557,10 @@ func (s *Client) LoopOut(globalCtx context.Context,
}
// Create a new swap object for this swap.
swapCfg := newSwapConfig(s.lndServices, s.Store, s.Server, s.assetClient)
swapCfg := newSwapConfig(
s.lndServices, s.Store, s.Server, s.assetClient,
)
initResult, err := newLoopOutSwap(
globalCtx, swapCfg, initiationHeight, request,
)
@ -574,6 +605,14 @@ func (s *Client) getExpiry(height int32, terms *LoopOutTerms,
func (s *Client) LoopOutQuote(ctx context.Context,
request *LoopOutQuoteRequest) (*LoopOutQuote, error) {
if request.AssetRFQRequest != nil {
rfqReq := request.AssetRFQRequest
if rfqReq.AssetId == nil || rfqReq.AssetEdgeNode == nil {
return nil, errors.New("both asset edge node and " +
"asset id must be set")
}
}
terms, err := s.Server.GetLoopOutTerms(ctx, request.Initiator)
if err != nil {
return nil, err
@ -608,12 +647,67 @@ func (s *Client) LoopOutQuote(ctx context.Context,
return nil, err
}
return &LoopOutQuote{
loopOutQuote := &LoopOutQuote{
SwapFee: quote.SwapFee,
MinerFee: minerFee,
PrepayAmount: quote.PrepayAmount,
SwapPaymentDest: quote.SwapPaymentDest,
}, nil
}
// If we use an Asset we'll rfq to get the asset amounts to use for
// the swap.
if request.AssetRFQRequest != nil {
rfqReq := request.AssetRFQRequest
if rfqReq.Expiry == 0 {
rfqReq.Expiry = time.Now().Add(defaultRFQExpiry).Unix()
}
if rfqReq.MaxLimitMultiplier == 0 {
rfqReq.MaxLimitMultiplier = defaultRFQMaxLimitMultiplier
}
// First we'll get the prepay rfq.
prepayRfq, err := s.assetClient.GetRfqForAsset(
ctx, quote.PrepayAmount, rfqReq.AssetId,
rfqReq.AssetEdgeNode, rfqReq.Expiry,
rfqReq.MaxLimitMultiplier,
)
if err != nil {
return nil, err
}
// The actual invoice swap amount is the requested amount plus
// the swap fee minus the prepay amount.
invoiceAmt := request.Amount + quote.SwapFee -
quote.PrepayAmount
swapRfq, err := s.assetClient.GetRfqForAsset(
ctx, invoiceAmt, rfqReq.AssetId,
rfqReq.AssetEdgeNode, rfqReq.Expiry,
rfqReq.MaxLimitMultiplier,
)
if err != nil {
return nil, err
}
// We'll also want the asset name to verify for the client.
assetName, err := s.assetClient.GetAssetName(
ctx, rfqReq.AssetId,
)
if err != nil {
return nil, err
}
loopOutQuote.LoopOutRfq = &LoopOutRfq{
PrepayRfqId: prepayRfq.Id,
PrepayAssetAmt: prepayRfq.AssetAmount,
SwapRfqId: swapRfq.Id,
SwapAssetAmt: swapRfq.AssetAmount,
AssetName: assetName,
}
}
return loopOutQuote, nil
}
// getLoopOutSweepFee is a helper method to estimate the loop out htlc sweep

2
go.mod
View file

@ -29,6 +29,7 @@ require (
github.com/lightningnetwork/lnd/clock v1.1.1
github.com/lightningnetwork/lnd/queue v1.1.1
github.com/lightningnetwork/lnd/ticker v1.1.1
github.com/lightningnetwork/lnd/tlv v1.2.6
github.com/lightningnetwork/lnd/tor v1.1.2
github.com/ory/dockertest/v3 v3.10.0
github.com/stretchr/testify v1.9.0
@ -123,7 +124,6 @@ require (
github.com/lightningnetwork/lnd/healthcheck v1.2.5 // indirect
github.com/lightningnetwork/lnd/kvdb v1.4.10 // indirect
github.com/lightningnetwork/lnd/sqldb v1.0.4 // indirect
github.com/lightningnetwork/lnd/tlv v1.2.6 // indirect
github.com/ltcsuite/ltcd v0.0.0-20190101042124-f37f8bf35796 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/matttproud/golang_protobuf_extensions v1.0.2-0.20181231171920-c182affec369 // indirect

View file

@ -98,6 +98,18 @@ type OutRequest struct {
// the configured maximum payment timeout) the total time spent may be
// a multiple of this value.
PaymentTimeout time.Duration
// AssetId is an optional asset id that can be used to specify the asset
// that will be used to pay for the swap. If this is set, a connection
// to a tapd server is required to pay for the asset.
AssetId []byte
// AssetPrepayRfqId is the rfq id that is used to pay the prepay
// invoice.
AssetPrepayRfqId []byte
// AssetSwapRfqId is the rfq id that is used to pay the swap invoice.
AssetSwapRfqId []byte
}
// Out contains the full details of a loop out request. This includes things
@ -145,6 +157,25 @@ type LoopOutQuoteRequest struct {
// initiated the swap (loop CLI, autolooper, LiT UI and so on) and is
// appended to the user agent string.
Initiator string
// AssetRFQRequest is the optional RFQ request that can be used to quote
// for asset rfqs using the asset client
AssetRFQRequest *AssetRFQRequest
}
type AssetRFQRequest struct {
// AssetId is the asset that we'll quote for.
AssetId []byte
// AssetEdgeNode is the pubkey of the peer that we'll quote for.
AssetEdgeNode []byte
// Expiry is the unix timestamp when the rfq will expire.
Expiry int64
// MaxLimitMultiplier is the multiplier that we'll use to calculate the
// max limit we'll quote for.
MaxLimitMultiplier float64
}
// LoopOutTerms are the server terms on which it executes swaps.
@ -181,6 +212,31 @@ type LoopOutQuote struct {
// SwapPaymentDest is the node pubkey where to swap payment needs to be
// sent to.
SwapPaymentDest [33]byte
// LoopOutRfq is the RFQ that can be used in the actual loop out to
// commit to an asset exchange rate.
LoopOutRfq *LoopOutRfq
}
// LoopOutRfq contains the details of an asset request for quote for a loop out
// swap.
type LoopOutRfq struct {
// PrepayRfqId is the ID of the prepay RFQ.
PrepayRfqId []byte
// PrepayAssetAmt is the amount of the asset that will be used to pay
// for the prepay invoice.
PrepayAssetAmt uint64
// SwapRfqId is the ID of the swap RFQ.
SwapRfqId []byte
// SwapAssetAmt is the amount of the asset that will be used to pay for
// the swap invoice.
SwapAssetAmt uint64
// AssetName is the human readable name of the asset.
AssetName string
}
// LoopInRequest contains the required parameters for the swap.
@ -430,6 +486,9 @@ type SwapInfo struct {
// channels that may be used to loop out. On a loop in this field
// is nil.
OutgoingChanSet loopdb.ChannelSet
// AssetSwapInfo contains the asset information for the swap.
AssetSwapInfo *loopdb.LoopOutAssetSwap
}
// LastUpdate returns the last update time of the swap.

View file

@ -210,6 +210,38 @@ func (s *swapClientServer) LoopOut(ctx context.Context,
PaymentTimeout: paymentTimeout,
}
// If the asset id is set, we need to set the asset amount and asset id
// in the request.
if in.AssetInfo != nil {
if len(in.AssetInfo.AssetId) != 0 &&
len(in.AssetInfo.AssetId) != 32 {
return nil, fmt.Errorf(
"asset id must be set to a 32 byte value",
)
}
if len(in.AssetRfqInfo.PrepayRfqId) != 0 &&
len(in.AssetRfqInfo.PrepayRfqId) != 32 {
return nil, fmt.Errorf(
"prepay rfq id must be set to a 32 byte value",
)
}
if len(in.AssetRfqInfo.SwapRfqId) != 0 &&
len(in.AssetRfqInfo.SwapRfqId) != 32 {
return nil, fmt.Errorf(
"swap rfq id must be set to a 32 byte value",
)
}
req.AssetId = in.AssetInfo.AssetId
req.AssetPrepayRfqId = in.AssetRfqInfo.PrepayRfqId
req.AssetSwapRfqId = in.AssetRfqInfo.SwapRfqId
}
switch {
case in.LoopOutChannel != 0 && len(in.OutgoingChanSet) > 0: // nolint:staticcheck
return nil, errors.New("loop_out_channel and outgoing_" +
@ -709,23 +741,52 @@ func (s *swapClientServer) LoopOutQuote(ctx context.Context,
req.SwapPublicationDeadline,
)
quote, err := s.impl.LoopOutQuote(ctx, &loop.LoopOutQuoteRequest{
loopOutQuoteReq := &loop.LoopOutQuoteRequest{
Amount: btcutil.Amount(req.Amt),
SweepConfTarget: confTarget,
SwapPublicationDeadline: publicactionDeadline,
Initiator: defaultLoopdInitiator,
})
}
if req.AssetInfo != nil {
if req.AssetInfo.AssetId == nil ||
req.AssetInfo.AssetEdgeNode == nil {
return nil, fmt.Errorf(
"asset id and edge node must both be set")
}
loopOutQuoteReq.AssetRFQRequest = &loop.AssetRFQRequest{
AssetId: req.AssetInfo.AssetId,
AssetEdgeNode: req.AssetInfo.AssetEdgeNode,
Expiry: req.AssetInfo.Expiry,
MaxLimitMultiplier: req.AssetInfo.MaxLimitMultiplier,
}
}
quote, err := s.impl.LoopOutQuote(ctx, loopOutQuoteReq)
if err != nil {
return nil, err
}
return &looprpc.OutQuoteResponse{
response := &looprpc.OutQuoteResponse{
HtlcSweepFeeSat: int64(quote.MinerFee),
PrepayAmtSat: int64(quote.PrepayAmount),
SwapFeeSat: int64(quote.SwapFee),
SwapPaymentDest: quote.SwapPaymentDest[:],
ConfTarget: confTarget,
}, nil
}
if quote.LoopOutRfq != nil {
response.AssetRfqInfo = &looprpc.AssetRfqInfo{
PrepayRfqId: quote.LoopOutRfq.PrepayRfqId,
PrepayAssetAmt: quote.LoopOutRfq.PrepayAssetAmt,
SwapRfqId: quote.LoopOutRfq.SwapRfqId,
SwapAssetAmt: quote.LoopOutRfq.SwapAssetAmt,
AssetName: quote.LoopOutRfq.AssetName,
}
}
return response, nil
}
// GetLoopInTerms returns the terms that the server enforces for swaps.
@ -2025,6 +2086,15 @@ func validateLoopOutRequest(ctx context.Context, lnd lndclient.LightningClient,
return 0, errInvalidAddress
}
// If this is an asset payment, we'll check that we have the necessary
// outbound asset capacaity to fulfill the request.
if req.AssetInfo != nil {
// Todo(sputn1ck) actually check outbound capacity.
return validateConfTarget(
req.SweepConfTarget, loop.DefaultSweepConfTarget,
)
}
// Check that the label is valid.
if err := labels.Validate(req.Label); err != nil {
return 0, err

View file

@ -65,6 +65,28 @@ type LoopOutContract struct {
// PaymentTimeout is the timeout for any individual off-chain payment
// attempt.
PaymentTimeout time.Duration
// AssetSwapInfo contains information, should the loop out swpa be
// paid via an asset channel.
AssetSwapInfo *LoopOutAssetSwap
}
type LoopOutAssetSwap struct {
// AssetId is the optional asset id that is used to pay the swap invoice.
AssetId []byte
// PrepayRfqId is the rfq id that is used to pay the prepay invoice.
PrepayRfqId []byte
// SwapRfqId is the rfq id that is used to pay the swap invoice.
SwapRfqId []byte
// PrepayPaidAmt is the asset amount that was paid for the prepay
// invoice.
PrepayPaidAmt uint64
// SwapPaidAmt is the asset amount that was paid for the swap invoice.
SwapPaidAmt uint64
}
// ChannelSet stores a set of channels.

View file

@ -20,10 +20,13 @@ import (
"github.com/lightninglabs/loop/sweep"
"github.com/lightninglabs/loop/sweepbatcher"
"github.com/lightninglabs/loop/utils"
"github.com/lightninglabs/taproot-assets/fn"
"github.com/lightninglabs/taproot-assets/rfqmsg"
"github.com/lightningnetwork/lnd/chainntnfs"
"github.com/lightningnetwork/lnd/channeldb"
"github.com/lightningnetwork/lnd/lnrpc"
"github.com/lightningnetwork/lnd/lntypes"
"github.com/lightningnetwork/lnd/tlv"
)
const (
@ -140,6 +143,18 @@ func newLoopOutSwap(globalCtx context.Context, cfg *swapConfig,
log.Infof("Initiating swap request at height %v: amt=%v, expiry=%v",
currentHeight, request.Amount, request.Expiry)
// If we have an asset id, we'll add that to the user agent.
if request.AssetId != nil {
if request.AssetPrepayRfqId == nil ||
request.AssetSwapRfqId == nil {
return nil, errors.New("both rfq ids must be set for " +
"asset swaps")
}
request.Initiator += " asset_out"
}
// The swap deadline will be given to the server for it to use as the
// latest swap publication time.
swapResp, err := cfg.server.NewLoopOutSwap(
@ -208,6 +223,14 @@ func newLoopOutSwap(globalCtx context.Context, cfg *swapConfig,
PaymentTimeout: request.PaymentTimeout,
}
if request.AssetId != nil {
contract.AssetSwapInfo = &loopdb.LoopOutAssetSwap{
AssetId: request.AssetId,
PrepayRfqId: request.AssetPrepayRfqId,
SwapRfqId: request.AssetSwapRfqId,
}
}
swapKit := newSwapKit(
swapHash, swap.TypeOut, cfg, &contract.SwapContract,
)
@ -633,20 +656,31 @@ func (s *loopOutSwap) payInvoices(ctx context.Context) {
}
// Use the recommended routing plugin.
var assetSwapRfq []byte
if s.isAssetSwap() {
assetSwapRfq = s.AssetSwapInfo.SwapRfqId
}
s.swapPaymentChan = s.payInvoice(
ctx, s.SwapInvoice, s.MaxSwapRoutingFee,
s.LoopOutContract.OutgoingChanSet,
s.LoopOutContract.PaymentTimeout, pluginType, true,
assetSwapRfq,
)
// Pay the prepay invoice. Won't use the routing plugin here as the
// prepay is trivially small and shouldn't normally need any help. We
// are sending it over the same channel as the loop out payment.
s.log.Infof("Sending prepayment %v", s.PrepayInvoice)
var assetPrepayRfq []byte
if s.isAssetSwap() {
assetPrepayRfq = s.AssetSwapInfo.PrepayRfqId
}
s.prePaymentChan = s.payInvoice(
ctx, s.PrepayInvoice, s.MaxPrepayRoutingFee,
s.LoopOutContract.OutgoingChanSet,
s.LoopOutContract.PaymentTimeout, RoutingPluginNone, false,
assetPrepayRfq,
)
}
@ -675,7 +709,7 @@ func (p paymentResult) failure() error {
func (s *loopOutSwap) payInvoice(ctx context.Context, invoice string,
maxFee btcutil.Amount, outgoingChanIds loopdb.ChannelSet,
paymentTimeout time.Duration, pluginType RoutingPluginType,
reportPluginResult bool) chan paymentResult {
reportPluginResult bool, rfqId []byte) chan paymentResult {
resultChan := make(chan paymentResult)
sendResult := func(result paymentResult) {
@ -690,7 +724,7 @@ func (s *loopOutSwap) payInvoice(ctx context.Context, invoice string,
status, err := s.payInvoiceAsync(
ctx, invoice, maxFee, outgoingChanIds, paymentTimeout,
pluginType, reportPluginResult,
pluginType, reportPluginResult, rfqId,
)
if err != nil {
result.err = err
@ -719,7 +753,7 @@ func (s *loopOutSwap) payInvoice(ctx context.Context, invoice string,
func (s *loopOutSwap) payInvoiceAsync(ctx context.Context,
invoice string, maxFee btcutil.Amount,
outgoingChanIds loopdb.ChannelSet, paymentTimeout time.Duration,
pluginType RoutingPluginType, reportPluginResult bool) (
pluginType RoutingPluginType, reportPluginResult bool, rfqId []byte) (
*lndclient.PaymentStatus, error) {
// Extract hash from payment request. Unfortunately the request
@ -782,6 +816,25 @@ func (s *loopOutSwap) payInvoiceAsync(ctx context.Context,
MaxParts: s.executeConfig.loopOutMaxParts,
}
// If we want an asset swap, we'll need to set the custom first hop
// data to the rfq id. This will then allow LND to route the payment
// through the asset channel, as the edge nodes tap will know about the
// payment through the rfq id.
if s.isAssetSwap() {
var rfq rfqmsg.ID
if n := copy(rfq[:], rfqId); n != 32 {
return nil, fmt.Errorf("rfq id has wrong length: %v", n)
}
htlc := rfqmsg.NewHtlc(nil, fn.Some(rfq))
htlcMapRecords, err := tlv.RecordsToMap(htlc.Records())
if err != nil {
return nil, err
}
req.FirstHopCustomRecords = htlcMapRecords
}
// Lookup state of the swap payment.
payCtx, cancel := context.WithCancel(ctx)
defer cancel()
@ -1404,3 +1457,8 @@ func (s *loopOutSwap) canSweep() bool {
return true
}
// isAssetSwap returns true if the swap is an asset swap.
func (s *loopOutSwap) isAssetSwap() bool {
return s.AssetSwapInfo != nil
}