diff --git a/assets/client.go b/assets/client.go index 4de89eb3f..e264036f5 100644 --- a/assets/client.go +++ b/assets/client.go @@ -13,12 +13,14 @@ 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" "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 +49,115 @@ 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 { + // 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 + + // 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. 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, minAssetAmount uint64) (*AssetInvoice, error) { + + if invoice == nil { + return nil, fmt.Errorf("invoice request must be set") + } + + req := &tapchannelrpc.AddInvoiceRequest{ + AssetId: 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[:], + } + } + + 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") + } + + 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 +} + // 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..7272f1f06 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,158 @@ 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, 0, + ) + 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, 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 62179dc2c..e2c5da3d0 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 @@ -801,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 d29f1345c..9ff814d3f 100644 --- a/cmd/loop/loopin.go +++ b/cmd/loop/loopin.go @@ -1,10 +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" @@ -85,6 +88,22 @@ 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 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, } @@ -151,6 +170,57 @@ func loopIn(ctx context.Context, cmd *cli.Command) error { return err } + var assetInfo *looprpc.AssetLoopInRequest + 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 { + 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) + } + if len(assetID) != 32 { + return fmt.Errorf("asset id must be a 32 byte value") + } + + 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) + } + } + 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, + MinAssetAmount: cmd.Uint64("min_asset_amount"), + } + } + quoteReq := &looprpc.QuoteRequest{ Amt: int64(amt), ConfTarget: htlcConfTarget, @@ -180,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")) { @@ -200,6 +274,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) @@ -209,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/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/docs/loop.1 b/docs/loop.1 index b906c4a99..d2f22668d 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 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 + .PP \fB--conf_target\fP="": the target number of blocks the on-chain htlc broadcast by the swap client should confirm within (default: 0) @@ -124,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 3c1c37642..870f3351e 100644 --- a/docs/loop.md +++ b/docs/loop.md @@ -98,18 +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` | -| `--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 34fd3d750..314396602 100644 --- a/docs/release-notes/release-notes-next.md +++ b/docs/release-notes/release-notes-next.md @@ -5,6 +5,10 @@ * Instant Out now validates server invoices against a caller-approved maximum swap fee. +* 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 * Instant Out requests must now set `max_swap_fee_sat`. Requests that omit the diff --git a/interface.go b/interface.go index 4b8623bf9..40edd4959 100644 --- a/interface.go +++ b/interface.go @@ -297,6 +297,19 @@ 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 selects the Taproot Asset channel peer that + // should convert the server's satoshi payment into the requested asset. + // 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. @@ -437,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 d7c52e209..76dd690a8 100644 --- a/loopd/swapclient_server.go +++ b/loopd/swapclient_server.go @@ -1399,6 +1399,20 @@ 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) != 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) if err != nil { @@ -1416,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 c047aefc1..cce13d5a4 100644 --- a/loopin.go +++ b/loopin.go @@ -1,12 +1,14 @@ package loop import ( + "bytes" "context" "crypto/rand" "crypto/sha256" "errors" "fmt" "sync" + "time" "github.com/btcsuite/btcd/btcec/v2" "github.com/btcsuite/btcd/btcutil" @@ -21,16 +23,24 @@ 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" "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. 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 // remaining blocks until the on-chain htlc expires. This value is used @@ -108,6 +118,7 @@ type loopInSwap struct { type loopInInitResult struct { swap *loopInSwap serverMessage string + assetAmount uint64 } // newLoopInSwap initiates a new loop in swap. @@ -123,6 +134,50 @@ 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 || + request.MinAssetAmount != 0) { + + return nil, fmt.Errorf("asset id must be set when asset " + + "options are 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) != 33 { + return nil, fmt.Errorf("asset edge node is required and " + + "must be a 33 byte public key") + } + + pubKey, err := btcec.ParsePubKey(request.AssetEdgeNode) + if err != nil { + return nil, fmt.Errorf("invalid asset edge node: %w", err) + } + assetPeer := route.NewVertex(pubKey) + + if request.LastHop != nil && + !bytes.Equal(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" + } + // 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 +246,131 @@ 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, - }, + var ( + swapInvoice string + assetAmount uint64 + assetRouteHints [][]zpay32.HopHint ) - if err != nil { - return nil, err + 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, request.MinAssetAmount, + ) + 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 + 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( + 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 { + // 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) + } + } 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. @@ -349,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 93d019da8..36355c2cb 100644 --- a/loopin_test.go +++ b/loopin_test.go @@ -2,24 +2,33 @@ package loop import ( "context" + "crypto/sha256" "fmt" "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" "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 +191,64 @@ func TestProcessHtlcSpendIgnoresGRPCAlreadySettled(t *testing.T) { require.Equal(t, loopdb.StateFailTimeout, initResult.swap.state) } +type tapdInvoiceClientMock struct { + tapchannelrpc.TaprootAssetChannelsClient + + peer route.Vertex + requests []*tapchannelrpc.AddInvoiceRequest + expiry time.Duration + rate string +} + +func (m *tapdInvoiceClientMock) AddInvoice(_ context.Context, + req *tapchannelrpc.AddInvoiceRequest, _ ...grpc.CallOption) ( + *tapchannelrpc.AddInvoiceResponse, error) { + + m.requests = append(m.requests, req) + + 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(), + Scid: 123, + AskAssetRate: &rfqrpc.FixedPoint{Coefficient: rate}, + Expiry: uint64(now.Add(lifetime).Unix()), + }, + 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 +363,213 @@ 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, + AssetEdgeNode: assetPeer[:], + MinAssetAmount: 490, + } + + initResult, err := newLoopInSwap(t.Context(), cfg, 600, &req) + require.NoError(t, err) + require.Len(t, invoiceClient.requests, 1) + + swapReq := invoiceClient.requests[0] + require.Equal(t, assetID, swapReq.AssetId) + 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( + 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) + + 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[:], 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) +} + +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: "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{ + 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) + }) + } +} + +// 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 e276533ee..88e1ab1a3 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,8 +1323,18 @@ 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"` + // 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 @@ -1381,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 { @@ -6493,6 +6514,73 @@ 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 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"` + // 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() { + *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 +} + +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. @@ -6517,7 +6605,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 +6617,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 +6630,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 +6719,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 +6731,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 +6744,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 +6775,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 +6787,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 +6800,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 +6848,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 +6860,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 +6873,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 +6924,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 +6936,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 +6949,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 +6994,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,8 +7008,11 @@ 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" + - "\fSwapResponse\x12\x12\n" + + " \x01(\bR\aprivate\x12:\n" + + "\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" + @@ -7297,7 +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\"\xcd\x02\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\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" + @@ -7457,7 +7552,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 +7640,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 +7802,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..5731e47db 100644 --- a/looprpc/client.proto +++ b/looprpc/client.proto @@ -474,9 +474,20 @@ 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 { + // 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. @@ -2436,6 +2447,27 @@ 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 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 { /* 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..7b03e6b9e 100644 --- a/looprpc/client.swagger.json +++ b/looprpc/client.swagger.json @@ -1449,6 +1449,26 @@ "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 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." + } + } + }, "looprpcAssetLoopOutInfo": { "type": "object", "properties": { @@ -2374,6 +2394,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." } } }, @@ -3045,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." 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.