From 1440b6bb1df63399bda23f83cc7599c5c37e5c50 Mon Sep 17 00:00:00 2001 From: sputn1ck Date: Thu, 9 Jan 2025 16:29:05 +0100 Subject: [PATCH] loopout: add asset loop out payment flow --- client.go | 106 ++++++++++++++++++++++++++++++++++--- go.mod | 2 +- interface.go | 59 +++++++++++++++++++++ loopd/swapclient_server.go | 78 +++++++++++++++++++++++++-- loopdb/loopout.go | 22 ++++++++ loopout.go | 64 ++++++++++++++++++++-- 6 files changed, 317 insertions(+), 14 deletions(-) diff --git a/client.go b/client.go index a1d19cfd..f7dbc22f 100644 --- a/client.go +++ b/client.go @@ -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 diff --git a/go.mod b/go.mod index f6de0e9f..725eda29 100644 --- a/go.mod +++ b/go.mod @@ -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 diff --git a/interface.go b/interface.go index 8764bf76..1d6845a6 100644 --- a/interface.go +++ b/interface.go @@ -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. diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index 3ed0d1fc..c8c04297 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -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 diff --git a/loopdb/loopout.go b/loopdb/loopout.go index 54fd274b..7f80af90 100644 --- a/loopdb/loopout.go +++ b/loopdb/loopout.go @@ -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. diff --git a/loopout.go b/loopout.go index d50fb26e..774d0f15 100644 --- a/loopout.go +++ b/loopout.go @@ -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 +}