From 41df4a7210d9f2baee6f73c6cf18e93c7f9d5a27 Mon Sep 17 00:00:00 2001 From: sputn1ck Date: Wed, 26 Aug 2026 14:44:41 +0200 Subject: [PATCH 1/6] assets: Add asset invoice creation Wrap tapd's asset-aware invoice RPC so Loop In can create regular and hold invoices payable through a selected asset channel. --- assets/client.go | 58 +++++++++++++++++++ assets/client_test.go | 129 ++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 187 insertions(+) diff --git a/assets/client.go b/assets/client.go index 4de89eb3f..3f2253182 100644 --- a/assets/client.go +++ b/assets/client.go @@ -19,6 +19,7 @@ import ( "github.com/lightninglabs/taproot-assets/taprpc/tapchannelrpc" "github.com/lightninglabs/taproot-assets/taprpc/universerpc" "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/macaroons" "google.golang.org/grpc" @@ -47,6 +48,63 @@ type TapdConfig struct { RFQtimeout time.Duration `long:"rfqtimeout" description:"The timeout we wait for tapd peer to accept RFQ"` } +// AssetInvoice contains the result of creating an invoice that is payable +// through a Taproot Asset channel. +type AssetInvoice struct { + // PaymentRequest is the BOLT 11 invoice containing the asset RFQ route + // hint. + PaymentRequest string + + // AcceptedBuyQuote is the quote backing the asset route hint. + AcceptedBuyQuote *rfqrpc.PeerAcceptedBuyQuote +} + +// AddAssetInvoice creates an invoice that receives the specified asset while +// retaining the satoshi amount set in the invoice request. If paymentHash is +// set, a hold invoice is created instead of a regular invoice. +func (c *TapdClient) AddAssetInvoice(ctx context.Context, assetID, + peerPubkey []byte, invoice *lnrpc.Invoice, + paymentHash *lntypes.Hash) (*AssetInvoice, error) { + + if invoice == nil { + return nil, fmt.Errorf("invoice request must be set") + } + + req := &tapchannelrpc.AddInvoiceRequest{ + AssetId: assetID, + PeerPubkey: peerPubkey, + InvoiceRequest: invoice, + } + if paymentHash != nil { + req.HodlInvoice = &tapchannelrpc.HodlInvoice{ + PaymentHash: paymentHash[:], + } + } + + resp, err := c.TaprootAssetChannelsClient.AddInvoice(ctx, req) + if err != nil { + return nil, err + } + if resp == nil { + return nil, fmt.Errorf("asset invoice response is nil") + } + if resp.GetAcceptedBuyQuote() == nil { + return nil, fmt.Errorf("asset invoice response has no accepted " + + "buy quote") + } + if resp.GetInvoiceResult() == nil || + resp.GetInvoiceResult().GetPaymentRequest() == "" { + + return nil, fmt.Errorf("asset invoice response has no payment " + + "request") + } + + return &AssetInvoice{ + PaymentRequest: resp.InvoiceResult.PaymentRequest, + AcceptedBuyQuote: resp.AcceptedBuyQuote, + }, nil +} + // DefaultTapdConfig returns a default configuration to connect to a taproot // assets daemon. func DefaultTapdConfig() *TapdConfig { diff --git a/assets/client_test.go b/assets/client_test.go index 49687e763..3314f0783 100644 --- a/assets/client_test.go +++ b/assets/client_test.go @@ -14,7 +14,10 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/lightninglabs/taproot-assets/taprpc/rfqrpc" + "github.com/lightninglabs/taproot-assets/taprpc/tapchannelrpc" "github.com/lightninglabs/taproot-assets/taprpc/universerpc" + "github.com/lightningnetwork/lnd/lnrpc" + "github.com/lightningnetwork/lnd/lntypes" "github.com/lightningnetwork/lnd/lnwire" "github.com/stretchr/testify/require" "google.golang.org/grpc" @@ -52,6 +55,23 @@ type staticRfqClient struct { response *rfqrpc.AddAssetSellOrderResponse } +type assetInvoiceClientMock struct { + tapchannelrpc.TaprootAssetChannelsClient + + request *tapchannelrpc.AddInvoiceRequest + response *tapchannelrpc.AddInvoiceResponse + err error +} + +func (m *assetInvoiceClientMock) AddInvoice(_ context.Context, + req *tapchannelrpc.AddInvoiceRequest, _ ...grpc.CallOption) ( + *tapchannelrpc.AddInvoiceResponse, error) { + + m.request = req + + return m.response, m.err +} + func (s *staticRfqClient) AddAssetSellOrder(context.Context, *rfqrpc.AddAssetSellOrderRequest, ...grpc.CallOption) ( *rfqrpc.AddAssetSellOrderResponse, error) { @@ -59,6 +79,115 @@ func (s *staticRfqClient) AddAssetSellOrder(context.Context, return s.response, nil } +func TestAddAssetInvoice(t *testing.T) { + t.Parallel() + + assetID := make([]byte, 32) + assetID[0] = 1 + peer := make([]byte, 33) + peer[0] = 2 + invoiceReq := &lnrpc.Invoice{ + Memo: "asset invoice", + ValueMsat: 50_000_000, + } + quote := &rfqrpc.PeerAcceptedBuyQuote{Peer: "peer"} + + tests := []struct { + name string + paymentHash *lntypes.Hash + }{ + { + name: "regular invoice", + }, + { + name: "hold invoice", + paymentHash: &lntypes.Hash{3}, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + mock := &assetInvoiceClientMock{ + response: &tapchannelrpc.AddInvoiceResponse{ + AcceptedBuyQuote: quote, + InvoiceResult: &lnrpc.AddInvoiceResponse{ + PaymentRequest: "invoice", + }, + }, + } + client := &TapdClient{ + TaprootAssetChannelsClient: mock, + } + + invoice, err := client.AddAssetInvoice( + t.Context(), assetID, peer, invoiceReq, + test.paymentHash, + ) + require.NoError(t, err) + require.Equal(t, "invoice", invoice.PaymentRequest) + require.Same(t, quote, invoice.AcceptedBuyQuote) + + require.Equal(t, assetID, mock.request.AssetId) + require.Equal(t, peer, mock.request.PeerPubkey) + require.Same(t, invoiceReq, mock.request.InvoiceRequest) + if test.paymentHash == nil { + require.Nil(t, mock.request.HodlInvoice) + } else { + require.Equal( + t, test.paymentHash[:], + mock.request.HodlInvoice.PaymentHash, + ) + } + }) + } +} + +func TestAddAssetInvoiceRejectsIncompleteResponse(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + response *tapchannelrpc.AddInvoiceResponse + err string + }{ + { + name: "nil response", + err: "asset invoice response is nil", + }, + { + name: "missing quote", + response: &tapchannelrpc.AddInvoiceResponse{}, + err: "asset invoice response has no accepted buy quote", + }, + { + name: "missing invoice", + response: &tapchannelrpc.AddInvoiceResponse{ + AcceptedBuyQuote: &rfqrpc.PeerAcceptedBuyQuote{}, + }, + err: "asset invoice response has no payment request", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + client := &TapdClient{ + TaprootAssetChannelsClient: &assetInvoiceClientMock{ + response: test.response, + }, + } + _, err := client.AddAssetInvoice( + t.Context(), make([]byte, 32), nil, + &lnrpc.Invoice{}, nil, + ) + require.ErrorContains(t, err, test.err) + }) + } +} + // TestDefaultTapdConfig tests that the default tapd connection paths match // tapd's mainnet defaults. func TestDefaultTapdConfig(t *testing.T) { From 7cdf2c3492256f8d00ae82775e291406979f025f Mon Sep 17 00:00:00 2001 From: sputn1ck Date: Wed, 26 Aug 2026 14:45:03 +0200 Subject: [PATCH 2/6] loopin: Route invoices over asset channels Create Loop In swap and probe invoices through tapd, pin both invoices to the negotiated asset edge, and pass that edge to the server as the final hop. --- client.go | 16 +++- interface.go | 9 +++ loopin.go | 164 +++++++++++++++++++++++++++++++------ loopin_test.go | 192 ++++++++++++++++++++++++++++++++++++++++++++ server_mock_test.go | 14 ++-- 5 files changed, 360 insertions(+), 35 deletions(-) diff --git a/client.go b/client.go index 62179dc2c..3dc3acb9e 100644 --- a/client.go +++ b/client.go @@ -766,10 +766,18 @@ func (s *Client) waitForInitialized(ctx context.Context) error { func (s *Client) LoopIn(globalCtx context.Context, request *LoopInRequest) (*LoopInSwapInfo, error) { - log.Infof("Loop in %v (last hop: %v)", - request.Amount, - request.LastHop, - ) + if request.AssetId != nil { + if s.AssetClient == nil { + return nil, errors.New("asset client must be set when " + + "using an asset id") + } + + log.Infof("Loop in %v with asset %x (last hop: %v)", + request.Amount, request.AssetId, request.LastHop) + } else { + log.Infof("Loop in %v (last hop: %v)", + request.Amount, request.LastHop) + } if err := s.waitForInitialized(globalCtx); err != nil { return nil, err diff --git a/interface.go b/interface.go index 4b8623bf9..c81f1f2fa 100644 --- a/interface.go +++ b/interface.go @@ -297,6 +297,15 @@ type LoopInRequest struct { // RouteHints are optional route hints to reach the destination through // private channels. RouteHints [][]zpay32.HopHint + + // AssetId is the optional asset ID to receive over a Taproot Asset + // channel when the server pays the swap invoice. + AssetId []byte + + // AssetEdgeNode optionally selects the Taproot Asset channel peer that + // should convert the server's satoshi payment into the requested asset. + // If unset, tapd selects from the eligible asset channels. + AssetEdgeNode []byte } // StaticAddressLoopInRequest contains the required parameters for the swap. diff --git a/loopin.go b/loopin.go index c047aefc1..fb9883d3c 100644 --- a/loopin.go +++ b/loopin.go @@ -1,6 +1,7 @@ package loop import ( + "bytes" "context" "crypto/rand" "crypto/sha256" @@ -21,6 +22,7 @@ import ( "github.com/lightningnetwork/lnd/chainntnfs" invpkg "github.com/lightningnetwork/lnd/invoices" "github.com/lightningnetwork/lnd/keychain" + "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lnrpc/invoicesrpc" "github.com/lightningnetwork/lnd/lnrpc/walletrpc" "github.com/lightningnetwork/lnd/lntypes" @@ -31,6 +33,11 @@ import ( "google.golang.org/grpc/status" ) +// assetLoopInInvoiceExpiry is the invoice lifetime an asset edge must cover +// with its RFQ quote. If an on-chain funding transaction does not confirm in +// this window, the swap fails safely and the client can reclaim the HTLC. +const assetLoopInInvoiceExpiry = int64(60 * 60) + var ( // MaxLoopInAcceptDelta configures the maximum acceptable number of // remaining blocks until the on-chain htlc expires. This value is used @@ -123,6 +130,45 @@ func newLoopInSwap(globalCtx context.Context, cfg *swapConfig, return nil, fmt.Errorf("private and route_hints both set") } + assetLoopIn := request.AssetId != nil + if !assetLoopIn && len(request.AssetEdgeNode) != 0 { + return nil, fmt.Errorf("asset id must be set when asset edge " + + "node is set") + } + if assetLoopIn { + if len(request.AssetId) != 32 { + return nil, fmt.Errorf("asset id must be a 32 byte value") + } + if cfg.assets == nil { + return nil, fmt.Errorf("asset client must be set when " + + "using an asset id") + } + if request.Private || len(request.RouteHints) != 0 { + return nil, fmt.Errorf("private and route hints are not " + + "supported for asset loop ins") + } + + if len(request.AssetEdgeNode) != 0 { + assetPeer, err := route.NewVertexFromBytes( + request.AssetEdgeNode, + ) + if err != nil { + return nil, fmt.Errorf("invalid asset edge node: %w", err) + } + + if request.LastHop != nil && + !bytes.Equal(request.LastHop[:], assetPeer[:]) { + + return nil, fmt.Errorf("last hop and asset edge node " + + "must match") + } + + request.LastHop = &assetPeer + } + + request.Initiator += " asset_in" + } + // If Private is set, we generate route hints. if request.Private { // If last_hop is set, we'll only add channels with peers set to @@ -191,40 +237,106 @@ func newLoopInSwap(globalCtx context.Context, cfg *swapConfig, var senderKey [33]byte copy(senderKey[:], keyDesc.PubKey.SerializeCompressed()) - // Create the swap invoice in lnd. - _, swapInvoice, err := cfg.lnd.Client.AddInvoice( - globalCtx, &invoicesrpc.AddInvoiceData{ - Preimage: &swapPreimage, - Value: lnwire.NewMSatFromSatoshis(swapInvoiceAmt), - Memo: "swap", - Expiry: 3600 * 24 * 365, - RouteHints: request.RouteHints, - Private: true, - }, - ) - if err != nil { - return nil, err + var swapInvoice string + if assetLoopIn { + var assetPeer []byte + if request.LastHop != nil { + assetPeer = request.LastHop[:] + } + + assetInvoice, err := cfg.assets.AddAssetInvoice( + globalCtx, request.AssetId, assetPeer, &lnrpc.Invoice{ + Memo: "swap", + RPreimage: swapPreimage[:], + ValueMsat: int64(lnwire.NewMSatFromSatoshis(swapInvoiceAmt)), + Expiry: assetLoopInInvoiceExpiry, + Private: false, + }, nil, + ) + if err != nil { + return nil, fmt.Errorf("create asset swap invoice: %w", err) + } + + quotePeer, err := route.NewVertexFromStr( + assetInvoice.AcceptedBuyQuote.Peer, + ) + if err != nil { + return nil, fmt.Errorf("invalid asset invoice peer: %w", err) + } + if len(assetPeer) != 0 && !bytes.Equal(assetPeer, quotePeer[:]) { + return nil, fmt.Errorf("asset invoice peer does not match " + + "requested edge node") + } + + // Pin the probe and the server payment to the same edge node as + // the swap invoice. + request.LastHop = "ePeer + swapInvoice = assetInvoice.PaymentRequest + } else { + // Create the swap invoice in lnd. + _, swapInvoice, err = cfg.lnd.Client.AddInvoice( + globalCtx, &invoicesrpc.AddInvoiceData{ + Preimage: &swapPreimage, + Value: lnwire.NewMSatFromSatoshis(swapInvoiceAmt), + Memo: "swap", + Expiry: 3600 * 24 * 365, + RouteHints: request.RouteHints, + Private: true, + }, + ) + if err != nil { + return nil, err + } } - // Create the probe invoice in lnd. Derive the payment hash + // Create the probe invoice. Derive the payment hash // deterministically from the swap hash in such a way that the server // can be sure that we don't know the preimage. probeHash := lntypes.Hash(sha256.Sum256(swapHash[:])) probeHash[0] ^= 1 log.Infof("Creating probe invoice %v", probeHash) - probeInvoice, err := cfg.lnd.Invoices.AddHoldInvoice( - globalCtx, &invoicesrpc.AddInvoiceData{ - Hash: &probeHash, - Value: lnwire.NewMSatFromSatoshis(swapInvoiceAmt), - Memo: "loop in probe", - Expiry: 3600, - RouteHints: request.RouteHints, - Private: true, - }, - ) - if err != nil { - return nil, err + var probeInvoice string + if assetLoopIn { + assetProbe, err := cfg.assets.AddAssetInvoice( + globalCtx, request.AssetId, request.LastHop[:], + &lnrpc.Invoice{ + Memo: "loop in probe", + ValueMsat: int64(lnwire.NewMSatFromSatoshis(swapInvoiceAmt)), + Expiry: assetLoopInInvoiceExpiry, + Private: false, + }, &probeHash, + ) + if err != nil { + return nil, fmt.Errorf("create asset probe invoice: %w", err) + } + + probePeer, err := route.NewVertexFromStr( + assetProbe.AcceptedBuyQuote.Peer, + ) + if err != nil { + return nil, fmt.Errorf("invalid asset probe peer: %w", err) + } + if !bytes.Equal(request.LastHop[:], probePeer[:]) { + return nil, fmt.Errorf("asset probe and swap invoice peers " + + "do not match") + } + + probeInvoice = assetProbe.PaymentRequest + } else { + probeInvoice, err = cfg.lnd.Invoices.AddHoldInvoice( + globalCtx, &invoicesrpc.AddInvoiceData{ + Hash: &probeHash, + Value: lnwire.NewMSatFromSatoshis(swapInvoiceAmt), + Memo: "loop in probe", + Expiry: 3600, + RouteHints: request.RouteHints, + Private: true, + }, + ) + if err != nil { + return nil, err + } } // Default the HTLC internal key to our sender key. diff --git a/loopin_test.go b/loopin_test.go index 93d019da8..89bee406f 100644 --- a/loopin_test.go +++ b/loopin_test.go @@ -2,6 +2,7 @@ package loop import ( "context" + "crypto/sha256" "fmt" "testing" "time" @@ -9,17 +10,23 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/lndclient" + "github.com/lightninglabs/loop/assets" "github.com/lightninglabs/loop/loopdb" "github.com/lightninglabs/loop/swap" "github.com/lightninglabs/loop/test" "github.com/lightninglabs/loop/utils" + "github.com/lightninglabs/taproot-assets/taprpc/rfqrpc" + "github.com/lightninglabs/taproot-assets/taprpc/tapchannelrpc" "github.com/lightningnetwork/lnd/chainntnfs" "github.com/lightningnetwork/lnd/clock" invpkg "github.com/lightningnetwork/lnd/invoices" + "github.com/lightningnetwork/lnd/lnrpc" "github.com/lightningnetwork/lnd/lntypes" + "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/routing/route" "github.com/lightningnetwork/lnd/zpay32" "github.com/stretchr/testify/require" + "google.golang.org/grpc" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) @@ -182,6 +189,34 @@ func TestProcessHtlcSpendIgnoresGRPCAlreadySettled(t *testing.T) { require.Equal(t, loopdb.StateFailTimeout, initResult.swap.state) } +type tapdInvoiceClientMock struct { + tapchannelrpc.TaprootAssetChannelsClient + + peer route.Vertex + requests []*tapchannelrpc.AddInvoiceRequest +} + +func (m *tapdInvoiceClientMock) AddInvoice(_ context.Context, + req *tapchannelrpc.AddInvoiceRequest, _ ...grpc.CallOption) ( + *tapchannelrpc.AddInvoiceResponse, error) { + + m.requests = append(m.requests, req) + + paymentRequest := "asset swap invoice" + if req.HodlInvoice != nil { + paymentRequest = "asset probe invoice" + } + + return &tapchannelrpc.AddInvoiceResponse{ + AcceptedBuyQuote: &rfqrpc.PeerAcceptedBuyQuote{ + Peer: m.peer.String(), + }, + InvoiceResult: &lnrpc.AddInvoiceResponse{ + PaymentRequest: paymentRequest, + }, + }, nil +} + // SubscribeSingleInvoice returns the mock's preconfigured channels. func (p *probeInvoicesMock) SubscribeSingleInvoice(_ context.Context, _ lntypes.Hash) (<-chan lndclient.InvoiceUpdate, <-chan error, error) { @@ -296,6 +331,163 @@ func TestLoopInSwapInvoiceRouteHintsMatchProbe(t *testing.T) { test.RequireRouteHintsEqual(t, probeRouteHints, swapRouteHints) } +// TestLoopInAssetInvoices verifies that both invoices sent to the server are +// created by tapd and pinned to the same asset edge node. +func TestLoopInAssetInvoices(t *testing.T) { + t.Parallel() + + ctx := newLoopInTestContext(t) + assetID := make([]byte, 32) + assetID[0] = 1 + assetPeer, err := route.NewVertexFromStr(ctx.lnd.NodePubkey) + require.NoError(t, err) + + invoiceClient := &tapdInvoiceClientMock{peer: assetPeer} + assetClient := &assets.TapdClient{ + TaprootAssetChannelsClient: invoiceClient, + } + cfg := newSwapConfig( + &ctx.lnd.LndServices, ctx.store, ctx.server, assetClient, + clock.NewTestClock(time.Unix(123, 0)), + ) + + req := LoopInRequest{ + Amount: 50_000, + MaxSwapFee: 1_000, + HtlcConfTarget: 2, + Initiator: "test", + AssetId: assetID, + } + + initResult, err := newLoopInSwap(t.Context(), cfg, 600, &req) + require.NoError(t, err) + require.Len(t, invoiceClient.requests, 2) + + swapReq := invoiceClient.requests[0] + require.Equal(t, assetID, swapReq.AssetId) + require.Empty(t, swapReq.PeerPubkey) + require.Nil(t, swapReq.HodlInvoice) + require.Equal(t, "swap", swapReq.InvoiceRequest.Memo) + require.Equal( + t, assetLoopInInvoiceExpiry, swapReq.InvoiceRequest.Expiry, + ) + require.EqualValues( + t, lnwire.NewMSatFromSatoshis( + req.Amount-testSwapFee, + ), swapReq.InvoiceRequest.ValueMsat, + ) + swapPreimage := lntypes.Preimage(swapReq.InvoiceRequest.RPreimage) + require.Equal(t, initResult.swap.hash, swapPreimage.Hash()) + require.False(t, swapReq.InvoiceRequest.Private) + + probeReq := invoiceClient.requests[1] + require.Equal(t, assetID, probeReq.AssetId) + require.Equal(t, assetPeer[:], probeReq.PeerPubkey) + require.NotNil(t, probeReq.HodlInvoice) + require.Equal(t, "loop in probe", probeReq.InvoiceRequest.Memo) + require.Equal( + t, assetLoopInInvoiceExpiry, probeReq.InvoiceRequest.Expiry, + ) + require.False(t, probeReq.InvoiceRequest.Private) + + expectedProbeHash := lntypes.Hash(sha256.Sum256(initResult.swap.hash[:])) + expectedProbeHash[0] ^= 1 + require.Equal(t, expectedProbeHash[:], probeReq.HodlInvoice.PaymentHash) + + require.Equal(t, "asset swap invoice", ctx.server.swapInvoice) + require.Equal(t, "asset probe invoice", ctx.server.probeInvoice) + require.Equal(t, &assetPeer, ctx.server.loopInLastHop) + require.Equal(t, "test asset_in", ctx.server.loopInInitiator) + require.Equal(t, &assetPeer, initResult.swap.LastHop) +} + +func TestLoopInAssetRequestValidation(t *testing.T) { + t.Parallel() + + ctx := newLoopInTestContext(t) + assetClient := &assets.TapdClient{} + assetID := make([]byte, 32) + assetID[0] = 1 + assetPeer, err := route.NewVertexFromStr(ctx.lnd.NodePubkey) + require.NoError(t, err) + otherPeer := assetPeer + otherPeer[1] ^= 1 + + tests := []struct { + name string + request LoopInRequest + assetClient *assets.TapdClient + err string + }{ + { + name: "edge without asset id", + request: LoopInRequest{ + AssetEdgeNode: assetPeer[:], + }, + assetClient: assetClient, + err: "asset id must be set", + }, + { + name: "invalid asset id", + request: LoopInRequest{ + AssetId: []byte{1}, + }, + assetClient: assetClient, + err: "asset id must be a 32 byte value", + }, + { + name: "empty asset id", + request: LoopInRequest{ + AssetId: []byte{}, + }, + assetClient: assetClient, + err: "asset id must be a 32 byte value", + }, + { + name: "missing asset client", + request: LoopInRequest{ + AssetId: assetID, + }, + err: "asset client must be set", + }, + { + name: "private asset invoice", + request: LoopInRequest{ + AssetId: assetID, + Private: true, + }, + assetClient: assetClient, + err: "private and route hints are not supported", + }, + { + name: "mismatched last hop", + request: LoopInRequest{ + AssetId: assetID, + AssetEdgeNode: assetPeer[:], + LastHop: &otherPeer, + }, + assetClient: assetClient, + err: "last hop and asset edge node must match", + }, + } + + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + t.Parallel() + + cfg := newSwapConfig( + &ctx.lnd.LndServices, ctx.store, ctx.server, + testCase.assetClient, + clock.NewTestClock(time.Unix(123, 0)), + ) + _, err := newLoopInSwap( + t.Context(), cfg, 600, &testCase.request, + ) + require.ErrorContains(t, err, testCase.err) + }) + } +} + func testLoopInSuccess(t *testing.T) { defer test.Guard(t)() diff --git a/server_mock_test.go b/server_mock_test.go index e8bc47277..4e1b3caf2 100644 --- a/server_mock_test.go +++ b/server_mock_test.go @@ -49,10 +49,12 @@ type serverMock struct { height int32 - swapInvoice string - probeInvoice string - swapHash lntypes.Hash - prepayHash lntypes.Hash + swapInvoice string + probeInvoice string + swapHash lntypes.Hash + prepayHash lntypes.Hash + loopInLastHop *route.Vertex + loopInInitiator string // preimagePush is a channel that preimage pushes are sent into. preimagePush chan lntypes.Preimage @@ -168,7 +170,7 @@ func getInvoice(hash lntypes.Hash, amt btcutil.Amount, memo string) (string, err func (s *serverMock) NewLoopInSwap(_ context.Context, swapHash lntypes.Hash, amount btcutil.Amount, _, _ [33]byte, swapInvoice, probeInvoice string, - _ *route.Vertex, _ string) (*newLoopInResponse, error) { + lastHop *route.Vertex, initiator string) (*newLoopInResponse, error) { _, receiverKey := test.CreateKey(101) _, receiverInternalKey := test.CreateKey(102) @@ -187,6 +189,8 @@ func (s *serverMock) NewLoopInSwap(_ context.Context, swapHash lntypes.Hash, s.swapInvoice = swapInvoice s.probeInvoice = probeInvoice s.swapHash = swapHash + s.loopInLastHop = lastHop + s.loopInInitiator = initiator // Simulate the server paying the probe invoice and expect the client to // cancel the probe payment. From 9e8127e40d0ee00d957167906816714b7e7f9216 Mon Sep 17 00:00:00 2001 From: sputn1ck Date: Wed, 26 Aug 2026 14:45:14 +0200 Subject: [PATCH 3/6] looprpc: Expose asset Loop In options Let RPC and CLI callers select an asset and optional channel peer for Loop In while preserving the existing BTC invoice behavior by default. --- cmd/loop/loopin.go | 45 +++ .../sessions/basic-swaps/02_loop-in.json | 3 +- .../loopin/06_loop-in-external-force.json | 3 +- loopd/swapclient_server.go | 15 + looprpc/client.pb.go | 380 +++++++++++------- looprpc/client.proto | 21 + looprpc/client.swagger.json | 19 + 7 files changed, 331 insertions(+), 155 deletions(-) diff --git a/cmd/loop/loopin.go b/cmd/loop/loopin.go index d29f1345c..d8488c522 100644 --- a/cmd/loop/loopin.go +++ b/cmd/loop/loopin.go @@ -2,6 +2,7 @@ package main import ( "context" + "encoding/hex" "fmt" "strconv" @@ -85,6 +86,17 @@ var ( verboseFlag, routeHintsFlag, privateFlag, + &cli.StringFlag{ + Name: "asset_id", + Usage: "the asset ID to receive over an asset " + + "channel; requires loopd to be connected to a " + + "Taproot Assets daemon", + }, + &cli.StringFlag{ + Name: "asset_edge_node", + Usage: "the optional pubkey of the asset channel " + + "peer to use for the loop in", + }, }, Action: loopIn, } @@ -151,6 +163,38 @@ func loopIn(ctx context.Context, cmd *cli.Command) error { return err } + var assetInfo *looprpc.AssetLoopInRequest + if cmd.IsSet("asset_edge_node") && !cmd.IsSet("asset_id") { + return fmt.Errorf("asset_id must be set when asset_edge_node " + + "is set") + } + if cmd.IsSet("asset_id") { + if cmd.Bool(privateFlag.Name) || len(hints) != 0 { + return fmt.Errorf("private and route_hints are not supported " + + "when looping in an asset") + } + + assetID, err := hex.DecodeString(cmd.String("asset_id")) + if err != nil { + return fmt.Errorf("invalid asset id: %w", err) + } + + var assetEdgeNode []byte + if cmd.IsSet("asset_edge_node") { + assetEdgeNode, err = hex.DecodeString( + cmd.String("asset_edge_node"), + ) + if err != nil { + return fmt.Errorf("invalid asset edge node: %w", err) + } + } + + assetInfo = &looprpc.AssetLoopInRequest{ + AssetId: assetID, + AssetEdgeNode: assetEdgeNode, + } + } + quoteReq := &looprpc.QuoteRequest{ Amt: int64(amt), ConfTarget: htlcConfTarget, @@ -200,6 +244,7 @@ func loopIn(ctx context.Context, cmd *cli.Command) error { LastHop: lastHop, RouteHints: hints, Private: cmd.Bool(privateFlag.Name), + AssetInfo: assetInfo, } resp, err := client.LoopIn(ctx, req) diff --git a/cmd/loop/testdata/sessions/basic-swaps/02_loop-in.json b/cmd/loop/testdata/sessions/basic-swaps/02_loop-in.json index 1e627beea..0c22c974f 100644 --- a/cmd/loop/testdata/sessions/basic-swaps/02_loop-in.json +++ b/cmd/loop/testdata/sessions/basic-swaps/02_loop-in.json @@ -88,7 +88,8 @@ "label": "", "initiator": "loop-cli", "route_hints": [], - "private": false + "private": false, + "asset_info": null } } }, diff --git a/cmd/loop/testdata/sessions/loopin/06_loop-in-external-force.json b/cmd/loop/testdata/sessions/loopin/06_loop-in-external-force.json index ce757782c..94634540c 100644 --- a/cmd/loop/testdata/sessions/loopin/06_loop-in-external-force.json +++ b/cmd/loop/testdata/sessions/loopin/06_loop-in-external-force.json @@ -73,7 +73,8 @@ "label": "", "initiator": "loop-cli", "route_hints": [], - "private": false + "private": false, + "asset_info": null } } }, diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index d7c52e209..5f2dd0aeb 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -1399,6 +1399,21 @@ func (s *swapClientServer) LoopIn(ctx context.Context, Private: in.Private, RouteHints: routeHints, } + if in.AssetInfo != nil { + if len(in.AssetInfo.AssetId) != 32 { + return nil, fmt.Errorf("asset id must be set to a 32 byte " + + "value") + } + if len(in.AssetInfo.AssetEdgeNode) != 0 && + len(in.AssetInfo.AssetEdgeNode) != 33 { + + return nil, fmt.Errorf("asset edge node must be a 33 byte " + + "public key") + } + + req.AssetId = in.AssetInfo.AssetId + req.AssetEdgeNode = in.AssetInfo.AssetEdgeNode + } if in.LastHop != nil { lastHop, err := route.NewVertexFromBytes(in.LastHop) if err != nil { diff --git a/looprpc/client.pb.go b/looprpc/client.pb.go index e276533ee..5c4272039 100644 --- a/looprpc/client.pb.go +++ b/looprpc/client.pb.go @@ -1214,7 +1214,11 @@ type LoopInRequest struct { // Private indicates whether the destination node should be considered // private. In which case, loop will generate hophints to assist with // probing and payment. - Private bool `protobuf:"varint,10,opt,name=private,proto3" json:"private,omitempty"` + Private bool `protobuf:"varint,10,opt,name=private,proto3" json:"private,omitempty"` + // The optional asset information to use for the swap. If set, the swap and + // probe invoices are created by the connected Taproot Assets daemon so that + // the server's payment is delivered over an asset channel. + AssetInfo *AssetLoopInRequest `protobuf:"bytes,11,opt,name=asset_info,json=assetInfo,proto3" json:"asset_info,omitempty"` unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache } @@ -1319,6 +1323,13 @@ func (x *LoopInRequest) GetPrivate() bool { return false } +func (x *LoopInRequest) GetAssetInfo() *AssetLoopInRequest { + if x != nil { + return x.AssetInfo + } + return nil +} + type SwapResponse struct { state protoimpl.MessageState `protogen:"open.v1"` // Swap identifier to track status in the update stream that is returned from @@ -6493,6 +6504,62 @@ func (x *AssetLoopOutRequest) GetExpiry() int64 { return 0 } +type AssetLoopInRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + // The asset ID to receive when the swap invoice is paid. A Taproot Assets + // client must be connected to loopd when this field is set. + AssetId []byte `protobuf:"bytes,1,opt,name=asset_id,json=assetId,proto3" json:"asset_id,omitempty"` + // The optional node identity public key of the asset channel peer to use for + // RFQ negotiation. If omitted, tapd selects from the eligible asset channels. + AssetEdgeNode []byte `protobuf:"bytes,2,opt,name=asset_edge_node,json=assetEdgeNode,proto3" json:"asset_edge_node,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *AssetLoopInRequest) Reset() { + *x = AssetLoopInRequest{} + mi := &file_client_proto_msgTypes[76] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *AssetLoopInRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AssetLoopInRequest) ProtoMessage() {} + +func (x *AssetLoopInRequest) ProtoReflect() protoreflect.Message { + mi := &file_client_proto_msgTypes[76] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AssetLoopInRequest.ProtoReflect.Descriptor instead. +func (*AssetLoopInRequest) Descriptor() ([]byte, []int) { + return file_client_proto_rawDescGZIP(), []int{76} +} + +func (x *AssetLoopInRequest) GetAssetId() []byte { + if x != nil { + return x.AssetId + } + return nil +} + +func (x *AssetLoopInRequest) GetAssetEdgeNode() []byte { + if x != nil { + return x.AssetEdgeNode + } + return nil +} + type AssetRfqInfo struct { state protoimpl.MessageState `protogen:"open.v1"` // The Prepay RFQ ID to use to pay for the prepay invoice. @@ -6517,7 +6584,7 @@ type AssetRfqInfo struct { func (x *AssetRfqInfo) Reset() { *x = AssetRfqInfo{} - mi := &file_client_proto_msgTypes[76] + mi := &file_client_proto_msgTypes[77] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6529,7 +6596,7 @@ func (x *AssetRfqInfo) String() string { func (*AssetRfqInfo) ProtoMessage() {} func (x *AssetRfqInfo) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[76] + mi := &file_client_proto_msgTypes[77] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6542,7 +6609,7 @@ func (x *AssetRfqInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use AssetRfqInfo.ProtoReflect.Descriptor instead. func (*AssetRfqInfo) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{76} + return file_client_proto_rawDescGZIP(), []int{77} } func (x *AssetRfqInfo) GetPrepayRfqId() []byte { @@ -6631,7 +6698,7 @@ type FixedPoint struct { func (x *FixedPoint) Reset() { *x = FixedPoint{} - mi := &file_client_proto_msgTypes[77] + mi := &file_client_proto_msgTypes[78] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6643,7 +6710,7 @@ func (x *FixedPoint) String() string { func (*FixedPoint) ProtoMessage() {} func (x *FixedPoint) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[77] + mi := &file_client_proto_msgTypes[78] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6656,7 +6723,7 @@ func (x *FixedPoint) ProtoReflect() protoreflect.Message { // Deprecated: Use FixedPoint.ProtoReflect.Descriptor instead. func (*FixedPoint) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{77} + return file_client_proto_rawDescGZIP(), []int{78} } func (x *FixedPoint) GetCoefficient() string { @@ -6687,7 +6754,7 @@ type AssetLoopOutInfo struct { func (x *AssetLoopOutInfo) Reset() { *x = AssetLoopOutInfo{} - mi := &file_client_proto_msgTypes[78] + mi := &file_client_proto_msgTypes[79] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6699,7 +6766,7 @@ func (x *AssetLoopOutInfo) String() string { func (*AssetLoopOutInfo) ProtoMessage() {} func (x *AssetLoopOutInfo) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[78] + mi := &file_client_proto_msgTypes[79] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6712,7 +6779,7 @@ func (x *AssetLoopOutInfo) ProtoReflect() protoreflect.Message { // Deprecated: Use AssetLoopOutInfo.ProtoReflect.Descriptor instead. func (*AssetLoopOutInfo) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{78} + return file_client_proto_rawDescGZIP(), []int{79} } func (x *AssetLoopOutInfo) GetAssetId() string { @@ -6760,7 +6827,7 @@ type StatelessRecovery struct { func (x *StatelessRecovery) Reset() { *x = StatelessRecovery{} - mi := &file_client_proto_msgTypes[79] + mi := &file_client_proto_msgTypes[80] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6772,7 +6839,7 @@ func (x *StatelessRecovery) String() string { func (*StatelessRecovery) ProtoMessage() {} func (x *StatelessRecovery) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[79] + mi := &file_client_proto_msgTypes[80] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6785,7 +6852,7 @@ func (x *StatelessRecovery) ProtoReflect() protoreflect.Message { // Deprecated: Use StatelessRecovery.ProtoReflect.Descriptor instead. func (*StatelessRecovery) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{79} + return file_client_proto_rawDescGZIP(), []int{80} } func (x *StatelessRecovery) GetServerPubkey() []byte { @@ -6836,7 +6903,7 @@ type CooperativeSweep struct { func (x *CooperativeSweep) Reset() { *x = CooperativeSweep{} - mi := &file_client_proto_msgTypes[80] + mi := &file_client_proto_msgTypes[81] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -6848,7 +6915,7 @@ func (x *CooperativeSweep) String() string { func (*CooperativeSweep) ProtoMessage() {} func (x *CooperativeSweep) ProtoReflect() protoreflect.Message { - mi := &file_client_proto_msgTypes[80] + mi := &file_client_proto_msgTypes[81] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -6861,7 +6928,7 @@ func (x *CooperativeSweep) ProtoReflect() protoreflect.Message { // Deprecated: Use CooperativeSweep.ProtoReflect.Descriptor instead. func (*CooperativeSweep) Descriptor() ([]byte, []int) { - return file_client_proto_rawDescGZIP(), []int{80} + return file_client_proto_rawDescGZIP(), []int{81} } func (x *CooperativeSweep) GetPaymentAddress() []byte { @@ -6906,7 +6973,7 @@ const file_client_proto_rawDesc = "" + "\x0fpayment_timeout\x18\x13 \x01(\rR\x0epaymentTimeout\x12;\n" + "\n" + "asset_info\x18\x14 \x01(\v2\x1c.looprpc.AssetLoopOutRequestR\tassetInfo\x12;\n" + - "\x0easset_rfq_info\x18\x15 \x01(\v2\x15.looprpc.AssetRfqInfoR\fassetRfqInfo\"\xd4\x02\n" + + "\x0easset_rfq_info\x18\x15 \x01(\v2\x15.looprpc.AssetRfqInfoR\fassetRfqInfo\"\x90\x03\n" + "\rLoopInRequest\x12\x10\n" + "\x03amt\x18\x01 \x01(\x03R\x03amt\x12 \n" + "\fmax_swap_fee\x18\x02 \x01(\x03R\n" + @@ -6920,7 +6987,9 @@ const file_client_proto_rawDesc = "" + "\vroute_hints\x18\t \x03(\v2\x12.looprpc.RouteHintR\n" + "routeHints\x12\x18\n" + "\aprivate\x18\n" + - " \x01(\bR\aprivate\"\xeb\x01\n" + + " \x01(\bR\aprivate\x12:\n" + + "\n" + + "asset_info\x18\v \x01(\v2\x1b.looprpc.AssetLoopInRequestR\tassetInfo\"\xeb\x01\n" + "\fSwapResponse\x12\x12\n" + "\x02id\x18\x01 \x01(\tB\x02\x18\x01R\x02id\x12\x19\n" + "\bid_bytes\x18\x03 \x01(\fR\aidBytes\x12%\n" + @@ -7297,7 +7366,10 @@ const file_client_proto_rawDesc = "" + "\basset_id\x18\x01 \x01(\fR\aassetId\x12&\n" + "\x0fasset_edge_node\x18\x02 \x01(\fR\rassetEdgeNode\x120\n" + "\x14max_limit_multiplier\x18\x03 \x01(\x01R\x12maxLimitMultiplier\x12\x16\n" + - "\x06expiry\x18\x04 \x01(\x03R\x06expiry\"\xcd\x02\n" + + "\x06expiry\x18\x04 \x01(\x03R\x06expiry\"W\n" + + "\x12AssetLoopInRequest\x12\x19\n" + + "\basset_id\x18\x01 \x01(\fR\aassetId\x12&\n" + + "\x0fasset_edge_node\x18\x02 \x01(\fR\rassetEdgeNode\"\xcd\x02\n" + "\fAssetRfqInfo\x12\"\n" + "\rprepay_rfq_id\x18\x01 \x01(\fR\vprepayRfqId\x12/\n" + "\x14max_prepay_asset_amt\x18\x02 \x01(\x04R\x11maxPrepayAssetAmt\x12?\n" + @@ -7457,7 +7529,7 @@ func file_client_proto_rawDescGZIP() []byte { } var file_client_proto_enumTypes = make([]protoimpl.EnumInfo, 10) -var file_client_proto_msgTypes = make([]protoimpl.MessageInfo, 82) +var file_client_proto_msgTypes = make([]protoimpl.MessageInfo, 83) var file_client_proto_goTypes = []any{ (AddressType)(0), // 0: looprpc.AddressType (SwapType)(0), // 1: looprpc.SwapType @@ -7545,142 +7617,144 @@ var file_client_proto_goTypes = []any{ (*StaticAddressLoopInRequest)(nil), // 83: looprpc.StaticAddressLoopInRequest (*StaticAddressLoopInResponse)(nil), // 84: looprpc.StaticAddressLoopInResponse (*AssetLoopOutRequest)(nil), // 85: looprpc.AssetLoopOutRequest - (*AssetRfqInfo)(nil), // 86: looprpc.AssetRfqInfo - (*FixedPoint)(nil), // 87: looprpc.FixedPoint - (*AssetLoopOutInfo)(nil), // 88: looprpc.AssetLoopOutInfo - (*StatelessRecovery)(nil), // 89: looprpc.StatelessRecovery - (*CooperativeSweep)(nil), // 90: looprpc.CooperativeSweep - nil, // 91: looprpc.LiquidityParameters.EasyAssetParamsEntry - (*lnrpc.OpenChannelRequest)(nil), // 92: lnrpc.OpenChannelRequest - (*swapserverrpc.RouteHint)(nil), // 93: looprpc.RouteHint - (*lnrpc.OutPoint)(nil), // 94: lnrpc.OutPoint + (*AssetLoopInRequest)(nil), // 86: looprpc.AssetLoopInRequest + (*AssetRfqInfo)(nil), // 87: looprpc.AssetRfqInfo + (*FixedPoint)(nil), // 88: looprpc.FixedPoint + (*AssetLoopOutInfo)(nil), // 89: looprpc.AssetLoopOutInfo + (*StatelessRecovery)(nil), // 90: looprpc.StatelessRecovery + (*CooperativeSweep)(nil), // 91: looprpc.CooperativeSweep + nil, // 92: looprpc.LiquidityParameters.EasyAssetParamsEntry + (*lnrpc.OpenChannelRequest)(nil), // 93: lnrpc.OpenChannelRequest + (*swapserverrpc.RouteHint)(nil), // 94: looprpc.RouteHint + (*lnrpc.OutPoint)(nil), // 95: lnrpc.OutPoint } var file_client_proto_depIdxs = []int32{ - 92, // 0: looprpc.StaticOpenChannelRequest.open_channel_request:type_name -> lnrpc.OpenChannelRequest + 93, // 0: looprpc.StaticOpenChannelRequest.open_channel_request:type_name -> lnrpc.OpenChannelRequest 0, // 1: looprpc.LoopOutRequest.account_addr_type:type_name -> looprpc.AddressType 85, // 2: looprpc.LoopOutRequest.asset_info:type_name -> looprpc.AssetLoopOutRequest - 86, // 3: looprpc.LoopOutRequest.asset_rfq_info:type_name -> looprpc.AssetRfqInfo - 93, // 4: looprpc.LoopInRequest.route_hints:type_name -> looprpc.RouteHint - 1, // 5: looprpc.SwapStatus.type:type_name -> looprpc.SwapType - 2, // 6: looprpc.SwapStatus.state:type_name -> looprpc.SwapState - 8, // 7: looprpc.SwapStatus.static_loop_in_state:type_name -> looprpc.StaticAddressLoopInSwapState - 3, // 8: looprpc.SwapStatus.failure_reason:type_name -> looprpc.FailureReason - 88, // 9: looprpc.SwapStatus.asset_info:type_name -> looprpc.AssetLoopOutInfo - 20, // 10: looprpc.ListSwapsRequest.list_swap_filter:type_name -> looprpc.ListSwapsFilter - 9, // 11: looprpc.ListSwapsFilter.swap_type:type_name -> looprpc.ListSwapsFilter.SwapTypeFilter - 18, // 12: looprpc.ListSwapsResponse.swaps:type_name -> looprpc.SwapStatus - 89, // 13: looprpc.SweepHtlcRequest.stateless_recovery:type_name -> looprpc.StatelessRecovery - 90, // 14: looprpc.SweepHtlcRequest.cooperative:type_name -> looprpc.CooperativeSweep - 24, // 15: looprpc.SweepHtlcResponse.not_requested:type_name -> looprpc.PublishNotRequested - 25, // 16: looprpc.SweepHtlcResponse.published:type_name -> looprpc.PublishSucceeded - 26, // 17: looprpc.SweepHtlcResponse.failed:type_name -> looprpc.PublishFailed - 93, // 18: looprpc.QuoteRequest.loop_in_route_hints:type_name -> looprpc.RouteHint - 85, // 19: looprpc.QuoteRequest.asset_info:type_name -> looprpc.AssetLoopOutRequest - 86, // 20: looprpc.OutQuoteResponse.asset_rfq_info:type_name -> looprpc.AssetRfqInfo - 93, // 21: looprpc.ProbeRequest.route_hints:type_name -> looprpc.RouteHint - 40, // 22: looprpc.TokensResponse.tokens:type_name -> looprpc.L402Token - 41, // 23: looprpc.GetInfoResponse.loop_out_stats:type_name -> looprpc.LoopStats - 41, // 24: looprpc.GetInfoResponse.loop_in_stats:type_name -> looprpc.LoopStats - 47, // 25: looprpc.LiquidityParameters.rules:type_name -> looprpc.LiquidityRule - 0, // 26: looprpc.LiquidityParameters.account_addr_type:type_name -> looprpc.AddressType - 91, // 27: looprpc.LiquidityParameters.easy_asset_params:type_name -> looprpc.LiquidityParameters.EasyAssetParamsEntry - 4, // 28: looprpc.LiquidityParameters.loop_in_source:type_name -> looprpc.LoopInSource - 1, // 29: looprpc.LiquidityRule.swap_type:type_name -> looprpc.SwapType - 5, // 30: looprpc.LiquidityRule.type:type_name -> looprpc.LiquidityRuleType - 45, // 31: looprpc.SetLiquidityParamsRequest.parameters:type_name -> looprpc.LiquidityParameters - 6, // 32: looprpc.Disqualified.reason:type_name -> looprpc.AutoReason - 14, // 33: looprpc.SuggestSwapsResponse.loop_out:type_name -> looprpc.LoopOutRequest - 15, // 34: looprpc.SuggestSwapsResponse.loop_in:type_name -> looprpc.LoopInRequest - 83, // 35: looprpc.SuggestSwapsResponse.static_loop_in:type_name -> looprpc.StaticAddressLoopInRequest - 51, // 36: looprpc.SuggestSwapsResponse.disqualified:type_name -> looprpc.Disqualified - 57, // 37: looprpc.ListReservationsResponse.reservations:type_name -> looprpc.ClientReservation - 64, // 38: looprpc.ListInstantOutsResponse.swaps:type_name -> looprpc.InstantOut - 69, // 39: looprpc.ListUnspentDepositsResponse.utxos:type_name -> looprpc.Utxo - 94, // 40: looprpc.WithdrawDepositsRequest.outpoints:type_name -> lnrpc.OutPoint - 7, // 41: looprpc.ListStaticAddressDepositsRequest.state_filter:type_name -> looprpc.DepositState - 80, // 42: looprpc.ListStaticAddressDepositsResponse.filtered_deposits:type_name -> looprpc.Deposit - 81, // 43: looprpc.ListStaticAddressWithdrawalResponse.withdrawals:type_name -> looprpc.StaticAddressWithdrawal - 82, // 44: looprpc.ListStaticAddressSwapsResponse.swaps:type_name -> looprpc.StaticAddressLoopInSwap - 7, // 45: looprpc.Deposit.state:type_name -> looprpc.DepositState - 80, // 46: looprpc.StaticAddressWithdrawal.deposits:type_name -> looprpc.Deposit - 8, // 47: looprpc.StaticAddressLoopInSwap.state:type_name -> looprpc.StaticAddressLoopInSwapState - 80, // 48: looprpc.StaticAddressLoopInSwap.deposits:type_name -> looprpc.Deposit - 93, // 49: looprpc.StaticAddressLoopInRequest.route_hints:type_name -> looprpc.RouteHint - 80, // 50: looprpc.StaticAddressLoopInResponse.used_deposits:type_name -> looprpc.Deposit - 87, // 51: looprpc.AssetRfqInfo.prepay_asset_rate:type_name -> looprpc.FixedPoint - 87, // 52: looprpc.AssetRfqInfo.swap_asset_rate:type_name -> looprpc.FixedPoint - 46, // 53: looprpc.LiquidityParameters.EasyAssetParamsEntry.value:type_name -> looprpc.EasyAssetAutoloopParams - 14, // 54: looprpc.SwapClient.LoopOut:input_type -> looprpc.LoopOutRequest - 15, // 55: looprpc.SwapClient.LoopIn:input_type -> looprpc.LoopInRequest - 17, // 56: looprpc.SwapClient.Monitor:input_type -> looprpc.MonitorRequest - 19, // 57: looprpc.SwapClient.ListSwaps:input_type -> looprpc.ListSwapsRequest - 22, // 58: looprpc.SwapClient.SweepHtlc:input_type -> looprpc.SweepHtlcRequest - 27, // 59: looprpc.SwapClient.SwapInfo:input_type -> looprpc.SwapInfoRequest - 53, // 60: looprpc.SwapClient.AbandonSwap:input_type -> looprpc.AbandonSwapRequest - 28, // 61: looprpc.SwapClient.LoopOutTerms:input_type -> looprpc.TermsRequest - 31, // 62: looprpc.SwapClient.LoopOutQuote:input_type -> looprpc.QuoteRequest - 28, // 63: looprpc.SwapClient.GetLoopInTerms:input_type -> looprpc.TermsRequest - 31, // 64: looprpc.SwapClient.GetLoopInQuote:input_type -> looprpc.QuoteRequest - 34, // 65: looprpc.SwapClient.Probe:input_type -> looprpc.ProbeRequest - 36, // 66: looprpc.SwapClient.GetL402Tokens:input_type -> looprpc.TokensRequest - 36, // 67: looprpc.SwapClient.GetLsatTokens:input_type -> looprpc.TokensRequest - 38, // 68: looprpc.SwapClient.FetchL402Token:input_type -> looprpc.FetchL402TokenRequest - 42, // 69: looprpc.SwapClient.GetInfo:input_type -> looprpc.GetInfoRequest - 12, // 70: looprpc.SwapClient.StopDaemon:input_type -> looprpc.StopDaemonRequest - 44, // 71: looprpc.SwapClient.GetLiquidityParams:input_type -> looprpc.GetLiquidityParamsRequest - 48, // 72: looprpc.SwapClient.SetLiquidityParams:input_type -> looprpc.SetLiquidityParamsRequest - 50, // 73: looprpc.SwapClient.SuggestSwaps:input_type -> looprpc.SuggestSwapsRequest - 55, // 74: looprpc.SwapClient.ListReservations:input_type -> looprpc.ListReservationsRequest - 58, // 75: looprpc.SwapClient.InstantOut:input_type -> looprpc.InstantOutRequest - 60, // 76: looprpc.SwapClient.InstantOutQuote:input_type -> looprpc.InstantOutQuoteRequest - 62, // 77: looprpc.SwapClient.ListInstantOuts:input_type -> looprpc.ListInstantOutsRequest - 65, // 78: looprpc.SwapClient.NewStaticAddress:input_type -> looprpc.NewStaticAddressRequest - 67, // 79: looprpc.SwapClient.ListUnspentDeposits:input_type -> looprpc.ListUnspentDepositsRequest - 70, // 80: looprpc.SwapClient.WithdrawDeposits:input_type -> looprpc.WithdrawDepositsRequest - 72, // 81: looprpc.SwapClient.ListStaticAddressDeposits:input_type -> looprpc.ListStaticAddressDepositsRequest - 74, // 82: looprpc.SwapClient.ListStaticAddressWithdrawals:input_type -> looprpc.ListStaticAddressWithdrawalRequest - 76, // 83: looprpc.SwapClient.ListStaticAddressSwaps:input_type -> looprpc.ListStaticAddressSwapsRequest - 78, // 84: looprpc.SwapClient.GetStaticAddressSummary:input_type -> looprpc.StaticAddressSummaryRequest - 83, // 85: looprpc.SwapClient.StaticAddressLoopIn:input_type -> looprpc.StaticAddressLoopInRequest - 10, // 86: looprpc.SwapClient.StaticOpenChannel:input_type -> looprpc.StaticOpenChannelRequest - 16, // 87: looprpc.SwapClient.LoopOut:output_type -> looprpc.SwapResponse - 16, // 88: looprpc.SwapClient.LoopIn:output_type -> looprpc.SwapResponse - 18, // 89: looprpc.SwapClient.Monitor:output_type -> looprpc.SwapStatus - 21, // 90: looprpc.SwapClient.ListSwaps:output_type -> looprpc.ListSwapsResponse - 23, // 91: looprpc.SwapClient.SweepHtlc:output_type -> looprpc.SweepHtlcResponse - 18, // 92: looprpc.SwapClient.SwapInfo:output_type -> looprpc.SwapStatus - 54, // 93: looprpc.SwapClient.AbandonSwap:output_type -> looprpc.AbandonSwapResponse - 30, // 94: looprpc.SwapClient.LoopOutTerms:output_type -> looprpc.OutTermsResponse - 33, // 95: looprpc.SwapClient.LoopOutQuote:output_type -> looprpc.OutQuoteResponse - 29, // 96: looprpc.SwapClient.GetLoopInTerms:output_type -> looprpc.InTermsResponse - 32, // 97: looprpc.SwapClient.GetLoopInQuote:output_type -> looprpc.InQuoteResponse - 35, // 98: looprpc.SwapClient.Probe:output_type -> looprpc.ProbeResponse - 37, // 99: looprpc.SwapClient.GetL402Tokens:output_type -> looprpc.TokensResponse - 37, // 100: looprpc.SwapClient.GetLsatTokens:output_type -> looprpc.TokensResponse - 39, // 101: looprpc.SwapClient.FetchL402Token:output_type -> looprpc.FetchL402TokenResponse - 43, // 102: looprpc.SwapClient.GetInfo:output_type -> looprpc.GetInfoResponse - 13, // 103: looprpc.SwapClient.StopDaemon:output_type -> looprpc.StopDaemonResponse - 45, // 104: looprpc.SwapClient.GetLiquidityParams:output_type -> looprpc.LiquidityParameters - 49, // 105: looprpc.SwapClient.SetLiquidityParams:output_type -> looprpc.SetLiquidityParamsResponse - 52, // 106: looprpc.SwapClient.SuggestSwaps:output_type -> looprpc.SuggestSwapsResponse - 56, // 107: looprpc.SwapClient.ListReservations:output_type -> looprpc.ListReservationsResponse - 59, // 108: looprpc.SwapClient.InstantOut:output_type -> looprpc.InstantOutResponse - 61, // 109: looprpc.SwapClient.InstantOutQuote:output_type -> looprpc.InstantOutQuoteResponse - 63, // 110: looprpc.SwapClient.ListInstantOuts:output_type -> looprpc.ListInstantOutsResponse - 66, // 111: looprpc.SwapClient.NewStaticAddress:output_type -> looprpc.NewStaticAddressResponse - 68, // 112: looprpc.SwapClient.ListUnspentDeposits:output_type -> looprpc.ListUnspentDepositsResponse - 71, // 113: looprpc.SwapClient.WithdrawDeposits:output_type -> looprpc.WithdrawDepositsResponse - 73, // 114: looprpc.SwapClient.ListStaticAddressDeposits:output_type -> looprpc.ListStaticAddressDepositsResponse - 75, // 115: looprpc.SwapClient.ListStaticAddressWithdrawals:output_type -> looprpc.ListStaticAddressWithdrawalResponse - 77, // 116: looprpc.SwapClient.ListStaticAddressSwaps:output_type -> looprpc.ListStaticAddressSwapsResponse - 79, // 117: looprpc.SwapClient.GetStaticAddressSummary:output_type -> looprpc.StaticAddressSummaryResponse - 84, // 118: looprpc.SwapClient.StaticAddressLoopIn:output_type -> looprpc.StaticAddressLoopInResponse - 11, // 119: looprpc.SwapClient.StaticOpenChannel:output_type -> looprpc.StaticOpenChannelResponse - 87, // [87:120] is the sub-list for method output_type - 54, // [54:87] is the sub-list for method input_type - 54, // [54:54] is the sub-list for extension type_name - 54, // [54:54] is the sub-list for extension extendee - 0, // [0:54] is the sub-list for field type_name + 87, // 3: looprpc.LoopOutRequest.asset_rfq_info:type_name -> looprpc.AssetRfqInfo + 94, // 4: looprpc.LoopInRequest.route_hints:type_name -> looprpc.RouteHint + 86, // 5: looprpc.LoopInRequest.asset_info:type_name -> looprpc.AssetLoopInRequest + 1, // 6: looprpc.SwapStatus.type:type_name -> looprpc.SwapType + 2, // 7: looprpc.SwapStatus.state:type_name -> looprpc.SwapState + 8, // 8: looprpc.SwapStatus.static_loop_in_state:type_name -> looprpc.StaticAddressLoopInSwapState + 3, // 9: looprpc.SwapStatus.failure_reason:type_name -> looprpc.FailureReason + 89, // 10: looprpc.SwapStatus.asset_info:type_name -> looprpc.AssetLoopOutInfo + 20, // 11: looprpc.ListSwapsRequest.list_swap_filter:type_name -> looprpc.ListSwapsFilter + 9, // 12: looprpc.ListSwapsFilter.swap_type:type_name -> looprpc.ListSwapsFilter.SwapTypeFilter + 18, // 13: looprpc.ListSwapsResponse.swaps:type_name -> looprpc.SwapStatus + 90, // 14: looprpc.SweepHtlcRequest.stateless_recovery:type_name -> looprpc.StatelessRecovery + 91, // 15: looprpc.SweepHtlcRequest.cooperative:type_name -> looprpc.CooperativeSweep + 24, // 16: looprpc.SweepHtlcResponse.not_requested:type_name -> looprpc.PublishNotRequested + 25, // 17: looprpc.SweepHtlcResponse.published:type_name -> looprpc.PublishSucceeded + 26, // 18: looprpc.SweepHtlcResponse.failed:type_name -> looprpc.PublishFailed + 94, // 19: looprpc.QuoteRequest.loop_in_route_hints:type_name -> looprpc.RouteHint + 85, // 20: looprpc.QuoteRequest.asset_info:type_name -> looprpc.AssetLoopOutRequest + 87, // 21: looprpc.OutQuoteResponse.asset_rfq_info:type_name -> looprpc.AssetRfqInfo + 94, // 22: looprpc.ProbeRequest.route_hints:type_name -> looprpc.RouteHint + 40, // 23: looprpc.TokensResponse.tokens:type_name -> looprpc.L402Token + 41, // 24: looprpc.GetInfoResponse.loop_out_stats:type_name -> looprpc.LoopStats + 41, // 25: looprpc.GetInfoResponse.loop_in_stats:type_name -> looprpc.LoopStats + 47, // 26: looprpc.LiquidityParameters.rules:type_name -> looprpc.LiquidityRule + 0, // 27: looprpc.LiquidityParameters.account_addr_type:type_name -> looprpc.AddressType + 92, // 28: looprpc.LiquidityParameters.easy_asset_params:type_name -> looprpc.LiquidityParameters.EasyAssetParamsEntry + 4, // 29: looprpc.LiquidityParameters.loop_in_source:type_name -> looprpc.LoopInSource + 1, // 30: looprpc.LiquidityRule.swap_type:type_name -> looprpc.SwapType + 5, // 31: looprpc.LiquidityRule.type:type_name -> looprpc.LiquidityRuleType + 45, // 32: looprpc.SetLiquidityParamsRequest.parameters:type_name -> looprpc.LiquidityParameters + 6, // 33: looprpc.Disqualified.reason:type_name -> looprpc.AutoReason + 14, // 34: looprpc.SuggestSwapsResponse.loop_out:type_name -> looprpc.LoopOutRequest + 15, // 35: looprpc.SuggestSwapsResponse.loop_in:type_name -> looprpc.LoopInRequest + 83, // 36: looprpc.SuggestSwapsResponse.static_loop_in:type_name -> looprpc.StaticAddressLoopInRequest + 51, // 37: looprpc.SuggestSwapsResponse.disqualified:type_name -> looprpc.Disqualified + 57, // 38: looprpc.ListReservationsResponse.reservations:type_name -> looprpc.ClientReservation + 64, // 39: looprpc.ListInstantOutsResponse.swaps:type_name -> looprpc.InstantOut + 69, // 40: looprpc.ListUnspentDepositsResponse.utxos:type_name -> looprpc.Utxo + 95, // 41: looprpc.WithdrawDepositsRequest.outpoints:type_name -> lnrpc.OutPoint + 7, // 42: looprpc.ListStaticAddressDepositsRequest.state_filter:type_name -> looprpc.DepositState + 80, // 43: looprpc.ListStaticAddressDepositsResponse.filtered_deposits:type_name -> looprpc.Deposit + 81, // 44: looprpc.ListStaticAddressWithdrawalResponse.withdrawals:type_name -> looprpc.StaticAddressWithdrawal + 82, // 45: looprpc.ListStaticAddressSwapsResponse.swaps:type_name -> looprpc.StaticAddressLoopInSwap + 7, // 46: looprpc.Deposit.state:type_name -> looprpc.DepositState + 80, // 47: looprpc.StaticAddressWithdrawal.deposits:type_name -> looprpc.Deposit + 8, // 48: looprpc.StaticAddressLoopInSwap.state:type_name -> looprpc.StaticAddressLoopInSwapState + 80, // 49: looprpc.StaticAddressLoopInSwap.deposits:type_name -> looprpc.Deposit + 94, // 50: looprpc.StaticAddressLoopInRequest.route_hints:type_name -> looprpc.RouteHint + 80, // 51: looprpc.StaticAddressLoopInResponse.used_deposits:type_name -> looprpc.Deposit + 88, // 52: looprpc.AssetRfqInfo.prepay_asset_rate:type_name -> looprpc.FixedPoint + 88, // 53: looprpc.AssetRfqInfo.swap_asset_rate:type_name -> looprpc.FixedPoint + 46, // 54: looprpc.LiquidityParameters.EasyAssetParamsEntry.value:type_name -> looprpc.EasyAssetAutoloopParams + 14, // 55: looprpc.SwapClient.LoopOut:input_type -> looprpc.LoopOutRequest + 15, // 56: looprpc.SwapClient.LoopIn:input_type -> looprpc.LoopInRequest + 17, // 57: looprpc.SwapClient.Monitor:input_type -> looprpc.MonitorRequest + 19, // 58: looprpc.SwapClient.ListSwaps:input_type -> looprpc.ListSwapsRequest + 22, // 59: looprpc.SwapClient.SweepHtlc:input_type -> looprpc.SweepHtlcRequest + 27, // 60: looprpc.SwapClient.SwapInfo:input_type -> looprpc.SwapInfoRequest + 53, // 61: looprpc.SwapClient.AbandonSwap:input_type -> looprpc.AbandonSwapRequest + 28, // 62: looprpc.SwapClient.LoopOutTerms:input_type -> looprpc.TermsRequest + 31, // 63: looprpc.SwapClient.LoopOutQuote:input_type -> looprpc.QuoteRequest + 28, // 64: looprpc.SwapClient.GetLoopInTerms:input_type -> looprpc.TermsRequest + 31, // 65: looprpc.SwapClient.GetLoopInQuote:input_type -> looprpc.QuoteRequest + 34, // 66: looprpc.SwapClient.Probe:input_type -> looprpc.ProbeRequest + 36, // 67: looprpc.SwapClient.GetL402Tokens:input_type -> looprpc.TokensRequest + 36, // 68: looprpc.SwapClient.GetLsatTokens:input_type -> looprpc.TokensRequest + 38, // 69: looprpc.SwapClient.FetchL402Token:input_type -> looprpc.FetchL402TokenRequest + 42, // 70: looprpc.SwapClient.GetInfo:input_type -> looprpc.GetInfoRequest + 12, // 71: looprpc.SwapClient.StopDaemon:input_type -> looprpc.StopDaemonRequest + 44, // 72: looprpc.SwapClient.GetLiquidityParams:input_type -> looprpc.GetLiquidityParamsRequest + 48, // 73: looprpc.SwapClient.SetLiquidityParams:input_type -> looprpc.SetLiquidityParamsRequest + 50, // 74: looprpc.SwapClient.SuggestSwaps:input_type -> looprpc.SuggestSwapsRequest + 55, // 75: looprpc.SwapClient.ListReservations:input_type -> looprpc.ListReservationsRequest + 58, // 76: looprpc.SwapClient.InstantOut:input_type -> looprpc.InstantOutRequest + 60, // 77: looprpc.SwapClient.InstantOutQuote:input_type -> looprpc.InstantOutQuoteRequest + 62, // 78: looprpc.SwapClient.ListInstantOuts:input_type -> looprpc.ListInstantOutsRequest + 65, // 79: looprpc.SwapClient.NewStaticAddress:input_type -> looprpc.NewStaticAddressRequest + 67, // 80: looprpc.SwapClient.ListUnspentDeposits:input_type -> looprpc.ListUnspentDepositsRequest + 70, // 81: looprpc.SwapClient.WithdrawDeposits:input_type -> looprpc.WithdrawDepositsRequest + 72, // 82: looprpc.SwapClient.ListStaticAddressDeposits:input_type -> looprpc.ListStaticAddressDepositsRequest + 74, // 83: looprpc.SwapClient.ListStaticAddressWithdrawals:input_type -> looprpc.ListStaticAddressWithdrawalRequest + 76, // 84: looprpc.SwapClient.ListStaticAddressSwaps:input_type -> looprpc.ListStaticAddressSwapsRequest + 78, // 85: looprpc.SwapClient.GetStaticAddressSummary:input_type -> looprpc.StaticAddressSummaryRequest + 83, // 86: looprpc.SwapClient.StaticAddressLoopIn:input_type -> looprpc.StaticAddressLoopInRequest + 10, // 87: looprpc.SwapClient.StaticOpenChannel:input_type -> looprpc.StaticOpenChannelRequest + 16, // 88: looprpc.SwapClient.LoopOut:output_type -> looprpc.SwapResponse + 16, // 89: looprpc.SwapClient.LoopIn:output_type -> looprpc.SwapResponse + 18, // 90: looprpc.SwapClient.Monitor:output_type -> looprpc.SwapStatus + 21, // 91: looprpc.SwapClient.ListSwaps:output_type -> looprpc.ListSwapsResponse + 23, // 92: looprpc.SwapClient.SweepHtlc:output_type -> looprpc.SweepHtlcResponse + 18, // 93: looprpc.SwapClient.SwapInfo:output_type -> looprpc.SwapStatus + 54, // 94: looprpc.SwapClient.AbandonSwap:output_type -> looprpc.AbandonSwapResponse + 30, // 95: looprpc.SwapClient.LoopOutTerms:output_type -> looprpc.OutTermsResponse + 33, // 96: looprpc.SwapClient.LoopOutQuote:output_type -> looprpc.OutQuoteResponse + 29, // 97: looprpc.SwapClient.GetLoopInTerms:output_type -> looprpc.InTermsResponse + 32, // 98: looprpc.SwapClient.GetLoopInQuote:output_type -> looprpc.InQuoteResponse + 35, // 99: looprpc.SwapClient.Probe:output_type -> looprpc.ProbeResponse + 37, // 100: looprpc.SwapClient.GetL402Tokens:output_type -> looprpc.TokensResponse + 37, // 101: looprpc.SwapClient.GetLsatTokens:output_type -> looprpc.TokensResponse + 39, // 102: looprpc.SwapClient.FetchL402Token:output_type -> looprpc.FetchL402TokenResponse + 43, // 103: looprpc.SwapClient.GetInfo:output_type -> looprpc.GetInfoResponse + 13, // 104: looprpc.SwapClient.StopDaemon:output_type -> looprpc.StopDaemonResponse + 45, // 105: looprpc.SwapClient.GetLiquidityParams:output_type -> looprpc.LiquidityParameters + 49, // 106: looprpc.SwapClient.SetLiquidityParams:output_type -> looprpc.SetLiquidityParamsResponse + 52, // 107: looprpc.SwapClient.SuggestSwaps:output_type -> looprpc.SuggestSwapsResponse + 56, // 108: looprpc.SwapClient.ListReservations:output_type -> looprpc.ListReservationsResponse + 59, // 109: looprpc.SwapClient.InstantOut:output_type -> looprpc.InstantOutResponse + 61, // 110: looprpc.SwapClient.InstantOutQuote:output_type -> looprpc.InstantOutQuoteResponse + 63, // 111: looprpc.SwapClient.ListInstantOuts:output_type -> looprpc.ListInstantOutsResponse + 66, // 112: looprpc.SwapClient.NewStaticAddress:output_type -> looprpc.NewStaticAddressResponse + 68, // 113: looprpc.SwapClient.ListUnspentDeposits:output_type -> looprpc.ListUnspentDepositsResponse + 71, // 114: looprpc.SwapClient.WithdrawDeposits:output_type -> looprpc.WithdrawDepositsResponse + 73, // 115: looprpc.SwapClient.ListStaticAddressDeposits:output_type -> looprpc.ListStaticAddressDepositsResponse + 75, // 116: looprpc.SwapClient.ListStaticAddressWithdrawals:output_type -> looprpc.ListStaticAddressWithdrawalResponse + 77, // 117: looprpc.SwapClient.ListStaticAddressSwaps:output_type -> looprpc.ListStaticAddressSwapsResponse + 79, // 118: looprpc.SwapClient.GetStaticAddressSummary:output_type -> looprpc.StaticAddressSummaryResponse + 84, // 119: looprpc.SwapClient.StaticAddressLoopIn:output_type -> looprpc.StaticAddressLoopInResponse + 11, // 120: looprpc.SwapClient.StaticOpenChannel:output_type -> looprpc.StaticOpenChannelResponse + 88, // [88:121] is the sub-list for method output_type + 55, // [55:88] is the sub-list for method input_type + 55, // [55:55] is the sub-list for extension type_name + 55, // [55:55] is the sub-list for extension extendee + 0, // [0:55] is the sub-list for field type_name } func init() { file_client_proto_init() } @@ -7705,7 +7779,7 @@ func file_client_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_client_proto_rawDesc), len(file_client_proto_rawDesc)), NumEnums: 10, - NumMessages: 82, + NumMessages: 83, NumExtensions: 0, NumServices: 1, }, diff --git a/looprpc/client.proto b/looprpc/client.proto index 89e66dc01..d66911800 100644 --- a/looprpc/client.proto +++ b/looprpc/client.proto @@ -474,6 +474,13 @@ message LoopInRequest { probing and payment. */ bool private = 10; + + /* + The optional asset information to use for the swap. If set, the swap and + probe invoices are created by the connected Taproot Assets daemon so that + the server's payment is delivered over an asset channel. + */ + AssetLoopInRequest asset_info = 11; } message SwapResponse { @@ -2436,6 +2443,20 @@ message AssetLoopOutRequest { int64 expiry = 4; } +message AssetLoopInRequest { + /* + The asset ID to receive when the swap invoice is paid. A Taproot Assets + client must be connected to loopd when this field is set. + */ + bytes asset_id = 1; + + /* + The optional node identity public key of the asset channel peer to use for + RFQ negotiation. If omitted, tapd selects from the eligible asset channels. + */ + bytes asset_edge_node = 2; +} + message AssetRfqInfo { /* The Prepay RFQ ID to use to pay for the prepay invoice. diff --git a/looprpc/client.swagger.json b/looprpc/client.swagger.json index d4692fbe4..fcfb458a5 100644 --- a/looprpc/client.swagger.json +++ b/looprpc/client.swagger.json @@ -1449,6 +1449,21 @@ "description": "- `unknown`: Unknown address type\n- `p2tr`: Pay to taproot pubkey (`TAPROOT_PUBKEY` = 1)", "title": "`AddressType` has to be one of:" }, + "looprpcAssetLoopInRequest": { + "type": "object", + "properties": { + "asset_id": { + "type": "string", + "format": "byte", + "description": "The asset ID to receive when the swap invoice is paid. A Taproot Assets\nclient must be connected to loopd when this field is set." + }, + "asset_edge_node": { + "type": "string", + "format": "byte", + "description": "The optional node identity public key of the asset channel peer to use for\nRFQ negotiation. If omitted, tapd selects from the eligible asset channels." + } + } + }, "looprpcAssetLoopOutInfo": { "type": "object", "properties": { @@ -2374,6 +2389,10 @@ "private": { "type": "boolean", "description": "Private indicates whether the destination node should be considered\nprivate. In which case, loop will generate hophints to assist with\nprobing and payment." + }, + "asset_info": { + "$ref": "#/definitions/looprpcAssetLoopInRequest", + "description": "The optional asset information to use for the swap. If set, the swap and\nprobe invoices are created by the connected Taproot Assets daemon so that\nthe server's payment is delivered over an asset channel." } } }, From 9502196f846bb3d7382e73d91d4580c1476c9f7d Mon Sep 17 00:00:00 2001 From: sputn1ck Date: Wed, 26 Aug 2026 14:48:03 +0200 Subject: [PATCH 4/6] docs: Note asset Loop In support Record the new Taproot Asset invoice path in the next Loop release notes. --- docs/release-notes/release-notes-next.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/release-notes/release-notes-next.md b/docs/release-notes/release-notes-next.md index 34fd3d750..9bdffceab 100644 --- a/docs/release-notes/release-notes-next.md +++ b/docs/release-notes/release-notes-next.md @@ -5,6 +5,9 @@ * Instant Out now validates server invoices against a caller-approved maximum swap fee. +* Loop In can now create its swap and probe invoices through `tapd`, allowing + the server's Lightning payment to be delivered over a Taproot Asset channel. + #### Breaking Changes * Instant Out requests must now set `max_swap_fee_sat`. Requests that omit the From fb00871326ab502baf38291543ecee9297b8e2cf Mon Sep 17 00:00:00 2001 From: sputn1ck Date: Wed, 26 Aug 2026 15:12:31 +0200 Subject: [PATCH 5/6] docs: Regenerate CLI reference Include the new asset Loop In flags in the generated man page and Markdown command reference. --- docs/loop.1 | 6 ++++++ docs/loop.md | 26 ++++++++++++++------------ 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/docs/loop.1 b/docs/loop.1 index b906c4a99..914ab8e43 100644 --- a/docs/loop.1 +++ b/docs/loop.1 @@ -106,6 +106,12 @@ perform an on-chain to off-chain swap (loop in) .PP \fB--amt\fP="": the amount in satoshis to loop in. To check for the minimum and maximum amounts to loop in please consult "loop terms" (default: 0) +.PP +\fB--asset_edge_node\fP="": the optional pubkey of the asset channel peer to use for the loop in + +.PP +\fB--asset_id\fP="": the asset ID to receive over an asset channel; requires loopd to be connected to a Taproot Assets daemon + .PP \fB--conf_target\fP="": the target number of blocks the on-chain htlc broadcast by the swap client should confirm within (default: 0) diff --git a/docs/loop.md b/docs/loop.md index 3c1c37642..fecdb8ec0 100644 --- a/docs/loop.md +++ b/docs/loop.md @@ -98,18 +98,20 @@ $ loop [GLOBAL FLAGS] in [COMMAND FLAGS] amt The following flags are supported: -| Name | Description | Type | Default value | -|---------------------|-------------------------------------------------------------------------------------------------------------------------|--------|:-------------:| -| `--amt="…"` | the amount in satoshis to loop in. To check for the minimum and maximum amounts to loop in please consult "loop terms" | uint | `0` | -| `--external` | expect htlc to be published externally | bool | `false` | -| `--conf_target="…"` | the target number of blocks the on-chain htlc broadcast by the swap client should confirm within | uint | `0` | -| `--last_hop="…"` | the pubkey of the last hop to use for this swap | string | -| `--label="…"` | an optional label for this swap,limited to 500 characters. The label may not start with our reserved prefix: [reserved] | string | -| `--force` | Assumes yes during confirmation. Using this option will result in an immediate swap | bool | `false` | -| `--verbose` (`-v`) | show expanded details | bool | `false` | -| `--route_hints="…"` | a JSON array of route hints that can each be individually used to assist in reaching the invoice's destination | string | -| `--private` | generates and passes routehints. Should be used if the connected node is only reachable via private channels | bool | `false` | -| `--help` (`-h`) | show help | bool | `false` | +| Name | Description | Type | Default value | +|-------------------------|-------------------------------------------------------------------------------------------------------------------------|--------|:-------------:| +| `--amt="…"` | the amount in satoshis to loop in. To check for the minimum and maximum amounts to loop in please consult "loop terms" | uint | `0` | +| `--external` | expect htlc to be published externally | bool | `false` | +| `--conf_target="…"` | the target number of blocks the on-chain htlc broadcast by the swap client should confirm within | uint | `0` | +| `--last_hop="…"` | the pubkey of the last hop to use for this swap | string | +| `--label="…"` | an optional label for this swap,limited to 500 characters. The label may not start with our reserved prefix: [reserved] | string | +| `--force` | Assumes yes during confirmation. Using this option will result in an immediate swap | bool | `false` | +| `--verbose` (`-v`) | show expanded details | bool | `false` | +| `--route_hints="…"` | a JSON array of route hints that can each be individually used to assist in reaching the invoice's destination | string | +| `--private` | generates and passes routehints. Should be used if the connected node is only reachable via private channels | bool | `false` | +| `--asset_id="…"` | the asset ID to receive over an asset channel; requires loopd to be connected to a Taproot Assets daemon | string | +| `--asset_edge_node="…"` | the optional pubkey of the asset channel peer to use for the loop in | string | +| `--help` (`-h`) | show help | bool | `false` | ### `terms` command From 62b84e23126c5126560b812dfa74034c2c346a97 Mon Sep 17 00:00:00 2001 From: sputn1ck Date: Mon, 7 Sep 2026 12:10:30 +0200 Subject: [PATCH 6/6] loopin: Enforce asset quote constraints Require an explicit edge and minimum asset output before accepting an asset Loop In. Apply the output floor to the RFQ and report the accepted amount to the caller. Reuse the same RFQ route hint for the probe and require quote validity for the full 30-day swap invoice lifetime. Validate CLI inputs before quoting and use the selected edge for both fee quotes and swap initiation. Cover the limits and shared probe hint with unit tests and regenerate RPC and CLI documentation. --- assets/client.go | 57 ++++++++++- assets/client_test.go | 47 ++++++++- client.go | 1 + cmd/loop/loopin.go | 45 +++++++-- cmd/loop/loopin_asset_test.go | 113 +++++++++++++++++++++ docs/loop.1 | 5 +- docs/loop.md | 29 +++--- docs/release-notes/release-notes-next.md | 5 +- interface.go | 11 ++- loopd/swapclient_server.go | 6 +- loopin.go | 121 +++++++++++++++-------- loopin_test.go | 120 ++++++++++++++++++---- looprpc/client.pb.go | 39 ++++++-- looprpc/client.proto | 15 ++- looprpc/client.swagger.json | 12 ++- 15 files changed, 521 insertions(+), 105 deletions(-) create mode 100644 cmd/loop/loopin_asset_test.go diff --git a/assets/client.go b/assets/client.go index 3f2253182..e264036f5 100644 --- a/assets/client.go +++ b/assets/client.go @@ -13,6 +13,7 @@ import ( "github.com/btcsuite/btcd/btcutil" "github.com/lightninglabs/taproot-assets/rfqmath" + "github.com/lightninglabs/taproot-assets/rpcutils" "github.com/lightninglabs/taproot-assets/taprpc" "github.com/lightninglabs/taproot-assets/taprpc/priceoraclerpc" "github.com/lightninglabs/taproot-assets/taprpc/rfqrpc" @@ -51,6 +52,9 @@ type TapdConfig struct { // AssetInvoice contains the result of creating an invoice that is payable // through a Taproot Asset channel. type AssetInvoice struct { + // AssetAmount is the quoted output in indivisible asset units. + AssetAmount uint64 + // PaymentRequest is the BOLT 11 invoice containing the asset RFQ route // hint. PaymentRequest string @@ -61,10 +65,12 @@ type AssetInvoice struct { // AddAssetInvoice creates an invoice that receives the specified asset while // retaining the satoshi amount set in the invoice request. If paymentHash is -// set, a hold invoice is created instead of a regular invoice. +// set, a hold invoice is created instead of a regular invoice. A nonzero +// minAssetAmount sets the RFQ rate floor and verifies the quoted output in +// indivisible asset units. func (c *TapdClient) AddAssetInvoice(ctx context.Context, assetID, peerPubkey []byte, invoice *lnrpc.Invoice, - paymentHash *lntypes.Hash) (*AssetInvoice, error) { + paymentHash *lntypes.Hash, minAssetAmount uint64) (*AssetInvoice, error) { if invoice == nil { return nil, fmt.Errorf("invoice request must be set") @@ -75,6 +81,28 @@ func (c *TapdClient) AddAssetInvoice(ctx context.Context, assetID, PeerPubkey: peerPubkey, InvoiceRequest: invoice, } + if minAssetAmount != 0 { + amt, err := lnrpc.UnmarshallAmt(invoice.Value, invoice.ValueMsat) + if err != nil || amt == 0 { + return nil, fmt.Errorf("positive invoice amount required " + + "for minimum asset output") + } + + // Round the rate floor up at eleven decimal places so the + // minimum output cannot be weakened by fixed-point truncation. + const scale = 11 + precision := new(big.Int).Exp(big.NewInt(10), big.NewInt(scale), nil) + numerator := new(big.Int).SetUint64(minAssetAmount) + numerator.Mul(numerator, big.NewInt(btcutil.SatoshiPerBitcoin*1000)) + numerator.Mul(numerator, precision) + denominator := new(big.Int).SetUint64(uint64(amt)) + numerator.Add(numerator, new(big.Int).Sub(denominator, big.NewInt(1))) + numerator.Div(numerator, denominator) + req.AssetRateLimit = &rfqrpc.FixedPoint{ + Coefficient: numerator.String(), + Scale: scale, + } + } if paymentHash != nil { req.HodlInvoice = &tapchannelrpc.HodlInvoice{ PaymentHash: paymentHash[:], @@ -99,7 +127,32 @@ func (c *TapdClient) AddAssetInvoice(ctx context.Context, assetID, "request") } + var assetAmount uint64 + if minAssetAmount != 0 { + rate, err := rpcutils.UnmarshalRfqFixedPoint( + resp.AcceptedBuyQuote.AskAssetRate, + ) + if err != nil { + return nil, fmt.Errorf("invalid asset rate: %w", err) + } + amt, err := lnrpc.UnmarshallAmt(invoice.Value, invoice.ValueMsat) + if err != nil { + return nil, err + } + units := rfqmath.MilliSatoshiToUnits(amt, *rate).ScaleTo(0) + var ok bool + assetAmount, ok = units.ToUint64Checked() + if !ok { + return nil, fmt.Errorf("quoted asset output overflows uint64") + } + if assetAmount < minAssetAmount { + return nil, fmt.Errorf("quoted asset output %d is below "+ + "minimum %d", assetAmount, minAssetAmount) + } + } + return &AssetInvoice{ + AssetAmount: assetAmount, PaymentRequest: resp.InvoiceResult.PaymentRequest, AcceptedBuyQuote: resp.AcceptedBuyQuote, }, nil diff --git a/assets/client_test.go b/assets/client_test.go index 3314f0783..7272f1f06 100644 --- a/assets/client_test.go +++ b/assets/client_test.go @@ -123,7 +123,7 @@ func TestAddAssetInvoice(t *testing.T) { invoice, err := client.AddAssetInvoice( t.Context(), assetID, peer, invoiceReq, - test.paymentHash, + test.paymentHash, 0, ) require.NoError(t, err) require.Equal(t, "invoice", invoice.PaymentRequest) @@ -181,13 +181,56 @@ func TestAddAssetInvoiceRejectsIncompleteResponse(t *testing.T) { } _, err := client.AddAssetInvoice( t.Context(), make([]byte, 32), nil, - &lnrpc.Invoice{}, nil, + &lnrpc.Invoice{}, nil, 0, ) require.ErrorContains(t, err, test.err) }) } } +// TestAssetInvoiceMinimum verifies per-swap limits reach tapd and an +// out-of-limit response is rejected before the caller can commit a swap. +func TestAssetInvoiceMinimum(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + minimum uint64 + err string + }{ + {name: "exact minimum", minimum: 500}, + {name: "under minimum", minimum: 501, err: "below minimum"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + mock := &assetInvoiceClientMock{ + response: &tapchannelrpc.AddInvoiceResponse{ + AcceptedBuyQuote: &rfqrpc.PeerAcceptedBuyQuote{ + AskAssetRate: &rfqrpc.FixedPoint{Coefficient: "1000000"}, + }, + InvoiceResult: &lnrpc.AddInvoiceResponse{PaymentRequest: "invoice"}, + }, + } + client := &TapdClient{TaprootAssetChannelsClient: mock} + invoice, err := client.AddAssetInvoice( + t.Context(), make([]byte, 32), nil, + &lnrpc.Invoice{ValueMsat: 50_000_000}, nil, tc.minimum, + ) + require.NotNil(t, mock.request.AssetRateLimit) + if tc.err != "" { + require.ErrorContains(t, err, tc.err) + return + } + require.NoError(t, err) + require.EqualValues(t, 500, invoice.AssetAmount) + require.Equal( + t, "100000000000000000", + mock.request.AssetRateLimit.Coefficient, + ) + require.EqualValues(t, 11, mock.request.AssetRateLimit.Scale) + }) + } +} + // TestDefaultTapdConfig tests that the default tapd connection paths match // tapd's mainnet defaults. func TestDefaultTapdConfig(t *testing.T) { diff --git a/client.go b/client.go index 3dc3acb9e..e2c5da3d0 100644 --- a/client.go +++ b/client.go @@ -809,6 +809,7 @@ func (s *Client) LoopIn(globalCtx context.Context, swapInfo := &LoopInSwapInfo{ SwapHash: swap.hash, ServerMessage: initResult.serverMessage, + AssetAmount: initResult.assetAmount, } if loopdb.CurrentProtocolVersion() < loopdb.ProtocolVersionHtlcV3 { diff --git a/cmd/loop/loopin.go b/cmd/loop/loopin.go index d8488c522..9ff814d3f 100644 --- a/cmd/loop/loopin.go +++ b/cmd/loop/loopin.go @@ -1,11 +1,13 @@ package main import ( + "bytes" "context" "encoding/hex" "fmt" "strconv" + "github.com/btcsuite/btcd/btcec/v2" "github.com/lightninglabs/loop" "github.com/lightninglabs/loop/labels" "github.com/lightninglabs/loop/looprpc" @@ -94,9 +96,14 @@ var ( }, &cli.StringFlag{ Name: "asset_edge_node", - Usage: "the optional pubkey of the asset channel " + + Usage: "the required pubkey of the asset channel " + "peer to use for the loop in", }, + &cli.Uint64Flag{ + Name: "min_asset_amount", + Usage: "minimum output in indivisible asset units; " + + "required with asset_id", + }, }, Action: loopIn, } @@ -164,9 +171,10 @@ func loopIn(ctx context.Context, cmd *cli.Command) error { } var assetInfo *looprpc.AssetLoopInRequest - if cmd.IsSet("asset_edge_node") && !cmd.IsSet("asset_id") { - return fmt.Errorf("asset_id must be set when asset_edge_node " + - "is set") + if (cmd.IsSet("asset_edge_node") || cmd.IsSet("min_asset_amount")) && + !cmd.IsSet("asset_id") { + + return fmt.Errorf("asset_id must be set with asset options") } if cmd.IsSet("asset_id") { if cmd.Bool(privateFlag.Name) || len(hints) != 0 { @@ -178,6 +186,9 @@ func loopIn(ctx context.Context, cmd *cli.Command) error { if err != nil { return fmt.Errorf("invalid asset id: %w", err) } + if len(assetID) != 32 { + return fmt.Errorf("asset id must be a 32 byte value") + } var assetEdgeNode []byte if cmd.IsSet("asset_edge_node") { @@ -188,10 +199,25 @@ func loopIn(ctx context.Context, cmd *cli.Command) error { return fmt.Errorf("invalid asset edge node: %w", err) } } + if len(assetEdgeNode) != 33 { + return fmt.Errorf("asset_edge_node is required and must " + + "be a 33 byte public key") + } + if _, err := btcec.ParsePubKey(assetEdgeNode); err != nil { + return fmt.Errorf("invalid asset edge node: %w", err) + } + if len(lastHop) != 0 && !bytes.Equal(lastHop, assetEdgeNode) { + return fmt.Errorf("last_hop and asset_edge_node must match") + } + lastHop = assetEdgeNode + if cmd.Uint64("min_asset_amount") == 0 { + return fmt.Errorf("min_asset_amount must be positive") + } assetInfo = &looprpc.AssetLoopInRequest{ - AssetId: assetID, - AssetEdgeNode: assetEdgeNode, + AssetId: assetID, + AssetEdgeNode: assetEdgeNode, + MinAssetAmount: cmd.Uint64("min_asset_amount"), } } @@ -224,6 +250,10 @@ func loopIn(ctx context.Context, cmd *cli.Command) error { } limits := getInLimits(quote) + if assetInfo != nil { + fmt.Printf("Minimum asset output: %d units of %x\n", + assetInfo.MinAssetAmount, assetInfo.AssetId) + } // Skip showing details if configured if !(cmd.Bool("force") || cmd.Bool("f")) { @@ -254,6 +284,9 @@ func loopIn(ctx context.Context, cmd *cli.Command) error { fmt.Printf("Swap initiated\n") fmt.Printf("ID: %x\n", resp.IdBytes) + if assetInfo != nil { + fmt.Printf("Quoted asset output: %d units\n", resp.AssetAmount) + } if resp.HtlcAddressP2Tr != "" { fmt.Printf("HTLC address (P2TR): %v\n", resp.HtlcAddressP2Tr) diff --git a/cmd/loop/loopin_asset_test.go b/cmd/loop/loopin_asset_test.go new file mode 100644 index 000000000..e48f83f8b --- /dev/null +++ b/cmd/loop/loopin_asset_test.go @@ -0,0 +1,113 @@ +package main + +import ( + "context" + "encoding/hex" + "errors" + "testing" + + "github.com/btcsuite/btcd/btcec/v2" + "github.com/lightninglabs/loop/looprpc" + "github.com/stretchr/testify/require" + "github.com/urfave/cli/v3" + "google.golang.org/grpc" +) + +type assetQuoteConn struct { + daemonConn + + quote *looprpc.QuoteRequest +} + +func (c *assetQuoteConn) Invoke(_ context.Context, _ string, req, _ any, + _ ...grpc.CallOption) error { + + c.quote = req.(*looprpc.QuoteRequest) + return errors.New("quote reached") +} + +type assetQuoteTransport struct { + grpcTransport + + conn *assetQuoteConn +} + +func (t *assetQuoteTransport) Dial(*cli.Command) (daemonConn, func(), error) { + return t.conn, func() {}, nil +} + +// TestAssetLoopInCLI checks early validation and the edge used for the fee +// quote. Malformed options must not reach any quote or swap RPC. +func TestAssetLoopInCLI(t *testing.T) { + _, pub := btcec.PrivKeyFromBytes([]byte{1}) + edge := hex.EncodeToString(pub.SerializeCompressed()) + _, otherPub := btcec.PrivKeyFromBytes([]byte{2}) + other := hex.EncodeToString(otherPub.SerializeCompressed()) + assetID := hex.EncodeToString(make([]byte, 32)) + base := []string{"loop", "in", "--amt", "500000", "--asset_id", assetID} + for _, tc := range []struct { + name string + args []string + err string + quoted bool + }{ + { + name: "short asset id", + args: []string{"--asset_id", "00"}, + err: "32 byte", + }, + { + name: "malformed asset id", + args: []string{"--asset_id", "zz"}, + err: "invalid asset id", + }, + { + name: "missing edge", + err: "asset_edge_node is required", + }, + { + name: "short edge", + args: []string{"--asset_edge_node", "02"}, + err: "33 byte", + }, + { + name: "missing minimum", + args: []string{"--asset_edge_node", edge}, + err: "min_asset_amount", + }, + { + name: "conflict", + args: []string{ + "--asset_edge_node", edge, "--last_hop", other, + }, + err: "must match", + }, + { + name: "edge quote", + args: []string{ + "--asset_edge_node", edge, + "--min_asset_amount", "4900", + }, + err: "quote reached", + quoted: true, + }, + } { + t.Run(tc.name, func(t *testing.T) { + conn := &assetQuoteConn{} + restore := hookGrpc(&assetQuoteTransport{conn: conn}) + defer restore() + cmd := newRootCommandForReplay() + args := append(append([]string{}, base...), tc.args...) + err := cmd.Run(t.Context(), args) + require.ErrorContains(t, err, tc.err) + if tc.quoted { + require.Equal( + t, pub.SerializeCompressed(), + conn.quote.LoopInLastHop, + ) + } else { + require.Nil(t, conn.quote) + } + }) + } +} diff --git a/docs/loop.1 b/docs/loop.1 index 914ab8e43..d2f22668d 100644 --- a/docs/loop.1 +++ b/docs/loop.1 @@ -107,7 +107,7 @@ perform an on-chain to off-chain swap (loop in) \fB--amt\fP="": the amount in satoshis to loop in. To check for the minimum and maximum amounts to loop in please consult "loop terms" (default: 0) .PP -\fB--asset_edge_node\fP="": the optional pubkey of the asset channel peer to use for the loop in +\fB--asset_edge_node\fP="": the required pubkey of the asset channel peer to use for the loop in .PP \fB--asset_id\fP="": the asset ID to receive over an asset channel; requires loopd to be connected to a Taproot Assets daemon @@ -130,6 +130,9 @@ perform an on-chain to off-chain swap (loop in) .PP \fB--last_hop\fP="": the pubkey of the last hop to use for this swap +.PP +\fB--min_asset_amount\fP="": minimum output in indivisible asset units; required with asset_id (default: 0) + .PP \fB--private\fP: generates and passes routehints. Should be used if the connected node is only reachable via private channels diff --git a/docs/loop.md b/docs/loop.md index fecdb8ec0..870f3351e 100644 --- a/docs/loop.md +++ b/docs/loop.md @@ -98,20 +98,21 @@ $ loop [GLOBAL FLAGS] in [COMMAND FLAGS] amt The following flags are supported: -| Name | Description | Type | Default value | -|-------------------------|-------------------------------------------------------------------------------------------------------------------------|--------|:-------------:| -| `--amt="…"` | the amount in satoshis to loop in. To check for the minimum and maximum amounts to loop in please consult "loop terms" | uint | `0` | -| `--external` | expect htlc to be published externally | bool | `false` | -| `--conf_target="…"` | the target number of blocks the on-chain htlc broadcast by the swap client should confirm within | uint | `0` | -| `--last_hop="…"` | the pubkey of the last hop to use for this swap | string | -| `--label="…"` | an optional label for this swap,limited to 500 characters. The label may not start with our reserved prefix: [reserved] | string | -| `--force` | Assumes yes during confirmation. Using this option will result in an immediate swap | bool | `false` | -| `--verbose` (`-v`) | show expanded details | bool | `false` | -| `--route_hints="…"` | a JSON array of route hints that can each be individually used to assist in reaching the invoice's destination | string | -| `--private` | generates and passes routehints. Should be used if the connected node is only reachable via private channels | bool | `false` | -| `--asset_id="…"` | the asset ID to receive over an asset channel; requires loopd to be connected to a Taproot Assets daemon | string | -| `--asset_edge_node="…"` | the optional pubkey of the asset channel peer to use for the loop in | string | -| `--help` (`-h`) | show help | bool | `false` | +| Name | Description | Type | Default value | +|--------------------------|-------------------------------------------------------------------------------------------------------------------------|--------|:-------------:| +| `--amt="…"` | the amount in satoshis to loop in. To check for the minimum and maximum amounts to loop in please consult "loop terms" | uint | `0` | +| `--external` | expect htlc to be published externally | bool | `false` | +| `--conf_target="…"` | the target number of blocks the on-chain htlc broadcast by the swap client should confirm within | uint | `0` | +| `--last_hop="…"` | the pubkey of the last hop to use for this swap | string | +| `--label="…"` | an optional label for this swap,limited to 500 characters. The label may not start with our reserved prefix: [reserved] | string | +| `--force` | Assumes yes during confirmation. Using this option will result in an immediate swap | bool | `false` | +| `--verbose` (`-v`) | show expanded details | bool | `false` | +| `--route_hints="…"` | a JSON array of route hints that can each be individually used to assist in reaching the invoice's destination | string | +| `--private` | generates and passes routehints. Should be used if the connected node is only reachable via private channels | bool | `false` | +| `--asset_id="…"` | the asset ID to receive over an asset channel; requires loopd to be connected to a Taproot Assets daemon | string | +| `--asset_edge_node="…"` | the required pubkey of the asset channel peer to use for the loop in | string | +| `--min_asset_amount="…"` | minimum output in indivisible asset units; required with asset_id | uint | `0` | +| `--help` (`-h`) | show help | bool | `false` | ### `terms` command diff --git a/docs/release-notes/release-notes-next.md b/docs/release-notes/release-notes-next.md index 9bdffceab..314396602 100644 --- a/docs/release-notes/release-notes-next.md +++ b/docs/release-notes/release-notes-next.md @@ -5,8 +5,9 @@ * Instant Out now validates server invoices against a caller-approved maximum swap fee. -* Loop In can now create its swap and probe invoices through `tapd`, allowing - the server's Lightning payment to be delivered over a Taproot Asset channel. +* Loop In can now receive over a Taproot Asset channel. Asset swaps require an + explicit edge and minimum asset output, reuse the swap RFQ for the probe, + and require a quote valid for the 30-day invoice lifetime before funding. #### Breaking Changes diff --git a/interface.go b/interface.go index c81f1f2fa..40edd4959 100644 --- a/interface.go +++ b/interface.go @@ -302,10 +302,14 @@ type LoopInRequest struct { // channel when the server pays the swap invoice. AssetId []byte - // AssetEdgeNode optionally selects the Taproot Asset channel peer that + // AssetEdgeNode selects the Taproot Asset channel peer that // should convert the server's satoshi payment into the requested asset. - // If unset, tapd selects from the eligible asset channels. + // It is required when AssetId is set. AssetEdgeNode []byte + + // MinAssetAmount is the minimum number of asset units to receive. It is + // required when AssetId is set and enforced before requesting a swap. + MinAssetAmount uint64 } // StaticAddressLoopInRequest contains the required parameters for the swap. @@ -446,6 +450,9 @@ type LoopInQuote struct { // LoopInSwapInfo contains essential information of a loop-in swap after the // swap is initiated. type LoopInSwapInfo struct { // nolint + // AssetAmount is the quoted asset output, or zero for a BTC swap. + AssetAmount uint64 + // SwapHash contains the sha256 hash of the swap preimage. SwapHash lntypes.Hash diff --git a/loopd/swapclient_server.go b/loopd/swapclient_server.go index 5f2dd0aeb..76dd690a8 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -1404,15 +1404,14 @@ func (s *swapClientServer) LoopIn(ctx context.Context, return nil, fmt.Errorf("asset id must be set to a 32 byte " + "value") } - if len(in.AssetInfo.AssetEdgeNode) != 0 && - len(in.AssetInfo.AssetEdgeNode) != 33 { - + if len(in.AssetInfo.AssetEdgeNode) != 33 { return nil, fmt.Errorf("asset edge node must be a 33 byte " + "public key") } req.AssetId = in.AssetInfo.AssetId req.AssetEdgeNode = in.AssetInfo.AssetEdgeNode + req.MinAssetAmount = in.AssetInfo.MinAssetAmount } if in.LastHop != nil { lastHop, err := route.NewVertexFromBytes(in.LastHop) @@ -1431,6 +1430,7 @@ func (s *swapClientServer) LoopIn(ctx context.Context, Id: swapInfo.SwapHash.String(), IdBytes: swapInfo.SwapHash[:], ServerMessage: swapInfo.ServerMessage, + AssetAmount: swapInfo.AssetAmount, } if loopdb.CurrentProtocolVersion() < loopdb.ProtocolVersionHtlcV3 { diff --git a/loopin.go b/loopin.go index fb9883d3c..cce13d5a4 100644 --- a/loopin.go +++ b/loopin.go @@ -8,6 +8,7 @@ import ( "errors" "fmt" "sync" + "time" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" @@ -29,14 +30,16 @@ import ( "github.com/lightningnetwork/lnd/lnwallet/chainfee" "github.com/lightningnetwork/lnd/lnwire" "github.com/lightningnetwork/lnd/routing/route" + "github.com/lightningnetwork/lnd/zpay32" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" ) // assetLoopInInvoiceExpiry is the invoice lifetime an asset edge must cover -// with its RFQ quote. If an on-chain funding transaction does not confirm in -// this window, the swap fails safely and the client can reclaim the HTLC. -const assetLoopInInvoiceExpiry = int64(60 * 60) +// with its RFQ quote. Thirty days covers the maximum accepted 1500-block +// contract at more than twice the target block interval, including payment +// retries. Edges that cannot honor this lifetime must reject before funding. +const assetLoopInInvoiceExpiry = int64(30 * 24 * 60 * 60) var ( // MaxLoopInAcceptDelta configures the maximum acceptable number of @@ -115,6 +118,7 @@ type loopInSwap struct { type loopInInitResult struct { swap *loopInSwap serverMessage string + assetAmount uint64 } // newLoopInSwap initiates a new loop in swap. @@ -131,9 +135,11 @@ func newLoopInSwap(globalCtx context.Context, cfg *swapConfig, } assetLoopIn := request.AssetId != nil - if !assetLoopIn && len(request.AssetEdgeNode) != 0 { - return nil, fmt.Errorf("asset id must be set when asset edge " + - "node is set") + if !assetLoopIn && (len(request.AssetEdgeNode) != 0 || + request.MinAssetAmount != 0) { + + return nil, fmt.Errorf("asset id must be set when asset " + + "options are set") } if assetLoopIn { if len(request.AssetId) != 32 { @@ -147,23 +153,26 @@ func newLoopInSwap(globalCtx context.Context, cfg *swapConfig, return nil, fmt.Errorf("private and route hints are not " + "supported for asset loop ins") } + if len(request.AssetEdgeNode) != 33 { + return nil, fmt.Errorf("asset edge node is required and " + + "must be a 33 byte public key") + } - if len(request.AssetEdgeNode) != 0 { - assetPeer, err := route.NewVertexFromBytes( - request.AssetEdgeNode, - ) - if err != nil { - return nil, fmt.Errorf("invalid asset edge node: %w", err) - } - - if request.LastHop != nil && - !bytes.Equal(request.LastHop[:], assetPeer[:]) { + pubKey, err := btcec.ParsePubKey(request.AssetEdgeNode) + if err != nil { + return nil, fmt.Errorf("invalid asset edge node: %w", err) + } + assetPeer := route.NewVertex(pubKey) - return nil, fmt.Errorf("last hop and asset edge node " + - "must match") - } + if request.LastHop != nil && + !bytes.Equal(request.LastHop[:], assetPeer[:]) { - request.LastHop = &assetPeer + return nil, fmt.Errorf("last hop and asset edge node " + + "must match") + } + request.LastHop = &assetPeer + if request.MinAssetAmount == 0 { + return nil, fmt.Errorf("minimum asset amount must be positive") } request.Initiator += " asset_in" @@ -237,7 +246,11 @@ func newLoopInSwap(globalCtx context.Context, cfg *swapConfig, var senderKey [33]byte copy(senderKey[:], keyDesc.PubKey.SerializeCompressed()) - var swapInvoice string + var ( + swapInvoice string + assetAmount uint64 + assetRouteHints [][]zpay32.HopHint + ) if assetLoopIn { var assetPeer []byte if request.LastHop != nil { @@ -251,7 +264,7 @@ func newLoopInSwap(globalCtx context.Context, cfg *swapConfig, ValueMsat: int64(lnwire.NewMSatFromSatoshis(swapInvoiceAmt)), Expiry: assetLoopInInvoiceExpiry, Private: false, - }, nil, + }, nil, request.MinAssetAmount, ) if err != nil { return nil, fmt.Errorf("create asset swap invoice: %w", err) @@ -272,6 +285,37 @@ func newLoopInSwap(globalCtx context.Context, cfg *swapConfig, // the swap invoice. request.LastHop = "ePeer swapInvoice = assetInvoice.PaymentRequest + assetAmount = assetInvoice.AssetAmount + decoded, err := zpay32.Decode(swapInvoice, cfg.lnd.ChainParams) + if err != nil { + return nil, fmt.Errorf("decode asset invoice: %w", err) + } + if decoded.MilliSat == nil || *decoded.MilliSat != + lnwire.NewMSatFromSatoshis(swapInvoiceAmt) { + + return nil, fmt.Errorf("asset invoice amount mismatch") + } + invoiceExpiry := time.Duration(assetLoopInInvoiceExpiry) * time.Second + quoteExpiry := time.Unix( + int64(assetInvoice.AcceptedBuyQuote.Expiry), 0, + ) + if decoded.Expiry() < invoiceExpiry || + quoteExpiry.Before(decoded.Timestamp.Add(decoded.Expiry())) { + + return nil, fmt.Errorf("asset quote must cover the full " + + "invoice lifetime") + } + assetRouteHints = decoded.RouteHints + if len(assetRouteHints) != 1 || len(assetRouteHints[0]) != 1 { + return nil, fmt.Errorf("asset invoice must have one RFQ hint") + } + hint := assetRouteHints[0][0] + if hint.ChannelID != assetInvoice.AcceptedBuyQuote.Scid || + !bytes.Equal(hint.NodeID.SerializeCompressed(), assetPeer) { + + return nil, fmt.Errorf("asset invoice must use the " + + "accepted edge RFQ") + } } else { // Create the swap invoice in lnd. _, swapInvoice, err = cfg.lnd.Client.AddInvoice( @@ -298,31 +342,21 @@ func newLoopInSwap(globalCtx context.Context, cfg *swapConfig, log.Infof("Creating probe invoice %v", probeHash) var probeInvoice string if assetLoopIn { - assetProbe, err := cfg.assets.AddAssetInvoice( - globalCtx, request.AssetId, request.LastHop[:], - &lnrpc.Invoice{ - Memo: "loop in probe", - ValueMsat: int64(lnwire.NewMSatFromSatoshis(swapInvoiceAmt)), - Expiry: assetLoopInInvoiceExpiry, - Private: false, - }, &probeHash, + // Reuse the exact RFQ hint. Negotiating another quote would + // probe a different rate, capacity limit and virtual SCID. + probeInvoice, err = cfg.lnd.Invoices.AddHoldInvoice( + globalCtx, &invoicesrpc.AddInvoiceData{ + Hash: &probeHash, + Memo: "loop in probe", + Value: lnwire.NewMSatFromSatoshis(swapInvoiceAmt), + Expiry: 3600, + RouteHints: assetRouteHints, + Private: false, + }, ) if err != nil { return nil, fmt.Errorf("create asset probe invoice: %w", err) } - - probePeer, err := route.NewVertexFromStr( - assetProbe.AcceptedBuyQuote.Peer, - ) - if err != nil { - return nil, fmt.Errorf("invalid asset probe peer: %w", err) - } - if !bytes.Equal(request.LastHop[:], probePeer[:]) { - return nil, fmt.Errorf("asset probe and swap invoice peers " + - "do not match") - } - - probeInvoice = assetProbe.PaymentRequest } else { probeInvoice, err = cfg.lnd.Invoices.AddHoldInvoice( globalCtx, &invoicesrpc.AddInvoiceData{ @@ -461,6 +495,7 @@ func newLoopInSwap(globalCtx context.Context, cfg *swapConfig, return &loopInInitResult{ swap: swap, serverMessage: swapResp.serverMessage, + assetAmount: assetAmount, }, nil } diff --git a/loopin_test.go b/loopin_test.go index 89bee406f..36355c2cb 100644 --- a/loopin_test.go +++ b/loopin_test.go @@ -7,7 +7,9 @@ import ( "testing" "time" + "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" + "github.com/btcsuite/btcd/chaincfg" "github.com/btcsuite/btcd/wire" "github.com/lightninglabs/lndclient" "github.com/lightninglabs/loop/assets" @@ -194,6 +196,8 @@ type tapdInvoiceClientMock struct { peer route.Vertex requests []*tapchannelrpc.AddInvoiceRequest + expiry time.Duration + rate string } func (m *tapdInvoiceClientMock) AddInvoice(_ context.Context, @@ -202,14 +206,42 @@ func (m *tapdInvoiceClientMock) AddInvoice(_ context.Context, m.requests = append(m.requests, req) - paymentRequest := "asset swap invoice" - if req.HodlInvoice != nil { - paymentRequest = "asset probe invoice" + peer, err := btcec.ParsePubKey(m.peer[:]) + if err != nil { + return nil, err + } + now := time.Now().Truncate(time.Second) + preimage := lntypes.Preimage(req.InvoiceRequest.RPreimage) + inv, err := zpay32.NewInvoice( + &chaincfg.TestNet3Params, + preimage.Hash(), now, + zpay32.Description("swap"), + zpay32.Amount(lnwire.MilliSatoshi(req.InvoiceRequest.ValueMsat)), + zpay32.Expiry(time.Duration(req.InvoiceRequest.Expiry)*time.Second), + zpay32.RouteHint([]zpay32.HopHint{{NodeID: peer, ChannelID: 123}}), + ) + if err != nil { + return nil, err + } + paymentRequest, err := test.EncodePayReq(inv) + if err != nil { + return nil, err + } + lifetime := time.Duration(req.InvoiceRequest.Expiry) * time.Second + if m.expiry != 0 { + lifetime = m.expiry + } + rate := m.rate + if rate == "" { + rate = "1000000" } return &tapchannelrpc.AddInvoiceResponse{ AcceptedBuyQuote: &rfqrpc.PeerAcceptedBuyQuote{ - Peer: m.peer.String(), + Peer: m.peer.String(), + Scid: 123, + AskAssetRate: &rfqrpc.FixedPoint{Coefficient: rate}, + Expiry: uint64(now.Add(lifetime).Unix()), }, InvoiceResult: &lnrpc.AddInvoiceResponse{ PaymentRequest: paymentRequest, @@ -357,15 +389,18 @@ func TestLoopInAssetInvoices(t *testing.T) { HtlcConfTarget: 2, Initiator: "test", AssetId: assetID, + AssetEdgeNode: assetPeer[:], + MinAssetAmount: 490, } initResult, err := newLoopInSwap(t.Context(), cfg, 600, &req) require.NoError(t, err) - require.Len(t, invoiceClient.requests, 2) + require.Len(t, invoiceClient.requests, 1) swapReq := invoiceClient.requests[0] require.Equal(t, assetID, swapReq.AssetId) - require.Empty(t, swapReq.PeerPubkey) + require.Equal(t, assetPeer[:], swapReq.PeerPubkey) + require.NotNil(t, swapReq.AssetRateLimit) require.Nil(t, swapReq.HodlInvoice) require.Equal(t, "swap", swapReq.InvoiceRequest.Memo) require.Equal( @@ -380,22 +415,17 @@ func TestLoopInAssetInvoices(t *testing.T) { require.Equal(t, initResult.swap.hash, swapPreimage.Hash()) require.False(t, swapReq.InvoiceRequest.Private) - probeReq := invoiceClient.requests[1] - require.Equal(t, assetID, probeReq.AssetId) - require.Equal(t, assetPeer[:], probeReq.PeerPubkey) - require.NotNil(t, probeReq.HodlInvoice) - require.Equal(t, "loop in probe", probeReq.InvoiceRequest.Memo) - require.Equal( - t, assetLoopInInvoiceExpiry, probeReq.InvoiceRequest.Expiry, - ) - require.False(t, probeReq.InvoiceRequest.Private) + probe, err := zpay32.Decode(ctx.server.probeInvoice, ctx.lnd.ChainParams) + require.NoError(t, err) + swapInvoice, err := zpay32.Decode(ctx.server.swapInvoice, ctx.lnd.ChainParams) + require.NoError(t, err) + test.RequireRouteHintsEqual(t, swapInvoice.RouteHints, probe.RouteHints) + require.Equal(t, *swapInvoice.MilliSat, *probe.MilliSat) + require.EqualValues(t, 497, initResult.assetAmount) expectedProbeHash := lntypes.Hash(sha256.Sum256(initResult.swap.hash[:])) expectedProbeHash[0] ^= 1 - require.Equal(t, expectedProbeHash[:], probeReq.HodlInvoice.PaymentHash) - - require.Equal(t, "asset swap invoice", ctx.server.swapInvoice) - require.Equal(t, "asset probe invoice", ctx.server.probeInvoice) + require.Equal(t, expectedProbeHash[:], probe.PaymentHash[:]) require.Equal(t, &assetPeer, ctx.server.loopInLastHop) require.Equal(t, "test asset_in", ctx.server.loopInInitiator) require.Equal(t, &assetPeer, initResult.swap.LastHop) @@ -419,6 +449,18 @@ func TestLoopInAssetRequestValidation(t *testing.T) { assetClient *assets.TapdClient err string }{ + { + name: "missing asset edge", + request: LoopInRequest{AssetId: assetID}, + assetClient: assetClient, + err: "asset edge node is required", + }, + { + name: "missing minimum output", + request: LoopInRequest{AssetId: assetID, AssetEdgeNode: assetPeer[:]}, + assetClient: assetClient, + err: "minimum asset amount must be positive", + }, { name: "edge without asset id", request: LoopInRequest{ @@ -488,6 +530,46 @@ func TestLoopInAssetRequestValidation(t *testing.T) { } } +// TestLoopInAssetQuoteRejection verifies invalid quotes never reach the +// server's swap admission or the local store, before any HTLC can be funded. +func TestLoopInAssetQuoteRejection(t *testing.T) { + t.Parallel() + for _, tc := range []struct { + name string + rate string + expiry time.Duration + err string + }{ + {name: "short quote", expiry: time.Hour, err: "full invoice lifetime"}, + {name: "below minimum", rate: "900000", err: "below minimum"}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + ctx := newLoopInTestContext(t) + peer, err := route.NewVertexFromStr(ctx.lnd.NodePubkey) + require.NoError(t, err) + mock := &tapdInvoiceClientMock{ + peer: peer, rate: tc.rate, expiry: tc.expiry, + } + cfg := newSwapConfig( + &ctx.lnd.LndServices, ctx.store, ctx.server, + &assets.TapdClient{TaprootAssetChannelsClient: mock}, + clock.NewTestClock(time.Unix(123, 0)), + ) + req := LoopInRequest{ + Amount: 50_000, MaxSwapFee: 1_000, + HtlcConfTarget: 2, Initiator: "test", + } + req.AssetId = make([]byte, 32) + req.AssetEdgeNode = peer[:] + req.MinAssetAmount = 490 + _, err = newLoopInSwap(t.Context(), cfg, 600, &req) + require.ErrorContains(t, err, tc.err) + require.Empty(t, ctx.server.swapInvoice) + }) + } +} + func testLoopInSuccess(t *testing.T) { defer test.Guard(t)() diff --git a/looprpc/client.pb.go b/looprpc/client.pb.go index 5c4272039..88e1ab1a3 100644 --- a/looprpc/client.pb.go +++ b/looprpc/client.pb.go @@ -1332,6 +1332,9 @@ func (x *LoopInRequest) GetAssetInfo() *AssetLoopInRequest { type SwapResponse struct { state protoimpl.MessageState `protogen:"open.v1"` + // The quoted asset output for an asset Loop In, in indivisible asset units. + // Zero for swaps that do not receive assets. + AssetAmount uint64 `protobuf:"varint,8,opt,name=asset_amount,json=assetAmount,proto3" json:"asset_amount,omitempty"` // Swap identifier to track status in the update stream that is returned from // the Start() call. Currently this is the hash that locks the htlcs. // DEPRECATED: To make the API more consistent, this field is deprecated in @@ -1392,6 +1395,13 @@ func (*SwapResponse) Descriptor() ([]byte, []int) { return file_client_proto_rawDescGZIP(), []int{6} } +func (x *SwapResponse) GetAssetAmount() uint64 { + if x != nil { + return x.AssetAmount + } + return 0 +} + // Deprecated: Marked as deprecated in client.proto. func (x *SwapResponse) GetId() string { if x != nil { @@ -6509,11 +6519,15 @@ type AssetLoopInRequest struct { // The asset ID to receive when the swap invoice is paid. A Taproot Assets // client must be connected to loopd when this field is set. AssetId []byte `protobuf:"bytes,1,opt,name=asset_id,json=assetId,proto3" json:"asset_id,omitempty"` - // The optional node identity public key of the asset channel peer to use for - // RFQ negotiation. If omitted, tapd selects from the eligible asset channels. + // The required node identity public key of the asset channel peer to use for + // RFQ negotiation and the server's fee quote. AssetEdgeNode []byte `protobuf:"bytes,2,opt,name=asset_edge_node,json=assetEdgeNode,proto3" json:"asset_edge_node,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // Required minimum output in indivisible asset units. The invoice remains + // BTC-denominated; the negotiated rate must yield at least this many units + // after the swap fee. Enforced before the swap is committed or funded. + MinAssetAmount uint64 `protobuf:"varint,3,opt,name=min_asset_amount,json=minAssetAmount,proto3" json:"min_asset_amount,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *AssetLoopInRequest) Reset() { @@ -6560,6 +6574,13 @@ func (x *AssetLoopInRequest) GetAssetEdgeNode() []byte { return nil } +func (x *AssetLoopInRequest) GetMinAssetAmount() uint64 { + if x != nil { + return x.MinAssetAmount + } + return 0 +} + type AssetRfqInfo struct { state protoimpl.MessageState `protogen:"open.v1"` // The Prepay RFQ ID to use to pay for the prepay invoice. @@ -6989,8 +7010,9 @@ const file_client_proto_rawDesc = "" + "\aprivate\x18\n" + " \x01(\bR\aprivate\x12:\n" + "\n" + - "asset_info\x18\v \x01(\v2\x1b.looprpc.AssetLoopInRequestR\tassetInfo\"\xeb\x01\n" + - "\fSwapResponse\x12\x12\n" + + "asset_info\x18\v \x01(\v2\x1b.looprpc.AssetLoopInRequestR\tassetInfo\"\x8e\x02\n" + + "\fSwapResponse\x12!\n" + + "\fasset_amount\x18\b \x01(\x04R\vassetAmount\x12\x12\n" + "\x02id\x18\x01 \x01(\tB\x02\x18\x01R\x02id\x12\x19\n" + "\bid_bytes\x18\x03 \x01(\fR\aidBytes\x12%\n" + "\fhtlc_address\x18\x02 \x01(\tB\x02\x18\x01R\vhtlcAddress\x12,\n" + @@ -7366,10 +7388,11 @@ const file_client_proto_rawDesc = "" + "\basset_id\x18\x01 \x01(\fR\aassetId\x12&\n" + "\x0fasset_edge_node\x18\x02 \x01(\fR\rassetEdgeNode\x120\n" + "\x14max_limit_multiplier\x18\x03 \x01(\x01R\x12maxLimitMultiplier\x12\x16\n" + - "\x06expiry\x18\x04 \x01(\x03R\x06expiry\"W\n" + + "\x06expiry\x18\x04 \x01(\x03R\x06expiry\"\x81\x01\n" + "\x12AssetLoopInRequest\x12\x19\n" + "\basset_id\x18\x01 \x01(\fR\aassetId\x12&\n" + - "\x0fasset_edge_node\x18\x02 \x01(\fR\rassetEdgeNode\"\xcd\x02\n" + + "\x0fasset_edge_node\x18\x02 \x01(\fR\rassetEdgeNode\x12(\n" + + "\x10min_asset_amount\x18\x03 \x01(\x04R\x0eminAssetAmount\"\xcd\x02\n" + "\fAssetRfqInfo\x12\"\n" + "\rprepay_rfq_id\x18\x01 \x01(\fR\vprepayRfqId\x12/\n" + "\x14max_prepay_asset_amt\x18\x02 \x01(\x04R\x11maxPrepayAssetAmt\x12?\n" + diff --git a/looprpc/client.proto b/looprpc/client.proto index d66911800..5731e47db 100644 --- a/looprpc/client.proto +++ b/looprpc/client.proto @@ -484,6 +484,10 @@ message LoopInRequest { } message SwapResponse { + // The quoted asset output for an asset Loop In, in indivisible asset units. + // Zero for swaps that do not receive assets. + uint64 asset_amount = 8; + /* Swap identifier to track status in the update stream that is returned from the Start() call. Currently this is the hash that locks the htlcs. @@ -2451,10 +2455,17 @@ message AssetLoopInRequest { bytes asset_id = 1; /* - The optional node identity public key of the asset channel peer to use for - RFQ negotiation. If omitted, tapd selects from the eligible asset channels. + The required node identity public key of the asset channel peer to use for + RFQ negotiation and the server's fee quote. */ bytes asset_edge_node = 2; + + /* + Required minimum output in indivisible asset units. The invoice remains + BTC-denominated; the negotiated rate must yield at least this many units + after the swap fee. Enforced before the swap is committed or funded. + */ + uint64 min_asset_amount = 3; } message AssetRfqInfo { diff --git a/looprpc/client.swagger.json b/looprpc/client.swagger.json index fcfb458a5..7b03e6b9e 100644 --- a/looprpc/client.swagger.json +++ b/looprpc/client.swagger.json @@ -1460,7 +1460,12 @@ "asset_edge_node": { "type": "string", "format": "byte", - "description": "The optional node identity public key of the asset channel peer to use for\nRFQ negotiation. If omitted, tapd selects from the eligible asset channels." + "description": "The required node identity public key of the asset channel peer to use for\nRFQ negotiation and the server's fee quote." + }, + "min_asset_amount": { + "type": "string", + "format": "uint64", + "description": "Required minimum output in indivisible asset units. The invoice remains\nBTC-denominated; the negotiated rate must yield at least this many units\nafter the swap fee. Enforced before the swap is committed or funded." } } }, @@ -3064,6 +3069,11 @@ "looprpcSwapResponse": { "type": "object", "properties": { + "asset_amount": { + "type": "string", + "format": "uint64", + "description": "The quoted asset output for an asset Loop In, in indivisible asset units.\nZero for swaps that do not receive assets." + }, "id": { "type": "string", "description": "Swap identifier to track status in the update stream that is returned from\nthe Start() call. Currently this is the hash that locks the htlcs.\nDEPRECATED: To make the API more consistent, this field is deprecated in\nfavor of id_bytes and will be removed in a future release."