Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 111 additions & 0 deletions assets/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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 {
Expand Down
172 changes: 172 additions & 0 deletions assets/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -52,13 +55,182 @@ 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) {

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) {
Expand Down
17 changes: 13 additions & 4 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading