loop/assets/client.go
Slyghtning cfbade1239
assets: reject malformed RFQ asset rates
Validate the rate pointer and decimal coefficient before converting
asset units. Return errors for nil, malformed, non-positive, and
oversized-scale rates instead of allowing nil dereferences or
division-by-zero panics. Add regression tests for each case.
2026-08-03 10:05:05 +02:00

394 lines
12 KiB
Go

package assets
import (
"context"
"encoding/hex"
"fmt"
"math"
"math/big"
"os"
"path/filepath"
"sync"
"time"
"github.com/btcsuite/btcd/btcutil"
"github.com/lightninglabs/taproot-assets/rfqmath"
"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/lnwire"
"github.com/lightningnetwork/lnd/macaroons"
"google.golang.org/grpc"
"google.golang.org/grpc/credentials"
"gopkg.in/macaroon.v2"
)
var (
// maxMsgRecvSize is the largest message our client will receive. We
// set this to 200MiB atm.
maxMsgRecvSize = grpc.MaxCallRecvMsgSize(1 * 1024 * 1024 * 200)
// defaultRfqTimeout is the default timeout we wait for tapd peer to
// accept RFQ.
defaultRfqTimeout = time.Second * 60
)
// TapdConfig is a struct that holds the configuration options to connect to a
// taproot assets daemon.
type TapdConfig struct {
Activate bool `long:"activate" description:"Activate the Tap daemon"`
Host string `long:"host" description:"The host of the Tap daemon, in the format of host:port"`
MacaroonPath string `long:"macaroonpath" description:"Path to the admin macaroon"`
TLSPath string `long:"tlspath" description:"Path to the TLS certificate"`
RFQtimeout time.Duration `long:"rfqtimeout" description:"The timeout we wait for tapd peer to accept RFQ"`
}
// DefaultTapdConfig returns a default configuration to connect to a taproot
// assets daemon.
func DefaultTapdConfig() *TapdConfig {
return DefaultTapdConfigForNetwork(
btcutil.AppDataDir("tapd", false), "mainnet",
)
}
// DefaultTapdConfigForNetwork returns the default tapd configuration rooted in
// defaultTapdDir for the given Bitcoin network. Passing the directory
// explicitly keeps path construction testable without accessing the user's
// real tapd data.
func DefaultTapdConfigForNetwork(defaultTapdDir, network string) *TapdConfig {
return &TapdConfig{
Activate: false,
Host: "localhost:10029",
MacaroonPath: filepath.Join(
defaultTapdDir, "data", network, "admin.macaroon",
),
TLSPath: filepath.Join(defaultTapdDir, "tls.cert"),
RFQtimeout: defaultRfqTimeout,
}
}
// TapdClient is a client for the Tap daemon.
type TapdClient struct {
taprpc.TaprootAssetsClient
tapchannelrpc.TaprootAssetChannelsClient
priceoraclerpc.PriceOracleClient
rfqrpc.RfqClient
universerpc.UniverseClient
rfqTimeoutSeconds uint32
assetNameCache map[string]string
assetNameMutex sync.RWMutex
cc *grpc.ClientConn
}
// NewTapdClient returns a new taproot assets client.
func NewTapdClient(config *TapdConfig) (*TapdClient, error) {
rfqTimeoutSeconds, err := getRfqTimeoutSeconds(config.RFQtimeout)
if err != nil {
return nil, err
}
// Create the client connection to the server.
conn, err := getClientConn(config)
if err != nil {
return nil, err
}
// Create the TapdClient.
client := &TapdClient{
assetNameCache: make(map[string]string),
cc: conn,
rfqTimeoutSeconds: rfqTimeoutSeconds,
TaprootAssetsClient: taprpc.NewTaprootAssetsClient(conn),
TaprootAssetChannelsClient: tapchannelrpc.NewTaprootAssetChannelsClient(conn),
PriceOracleClient: priceoraclerpc.NewPriceOracleClient(conn),
RfqClient: rfqrpc.NewRfqClient(conn),
UniverseClient: universerpc.NewUniverseClient(conn),
}
return client, nil
}
// Close closes the client connection to the server.
func (c *TapdClient) Close() {
c.cc.Close()
}
// GetRfqForAsset returns a RFQ for the given asset with the given amount and
// to the given peer.
func (c *TapdClient) GetRfqForAsset(ctx context.Context,
satAmount btcutil.Amount, assetId, peerPubkey []byte,
expiry int64, feeLimitMultiplier float64) (
*rfqrpc.PeerAcceptedSellQuote, error) {
// paymentMaxAmt is the maximum amount we are willing to pay for the
// payment.
// E.g. on a 250k sats payment we'll multiply the sat amount by 1.2.
// The resulting maximum amount we're willing to pay is 300k sats.
// The response asset amount will be for those 300k sats.
paymentMaxAmt, err := getPaymentMaxAmount(satAmount, feeLimitMultiplier)
if err != nil {
return nil, err
}
rfq, err := c.RfqClient.AddAssetSellOrder(
ctx, &rfqrpc.AddAssetSellOrderRequest{
AssetSpecifier: &rfqrpc.AssetSpecifier{
Id: &rfqrpc.AssetSpecifier_AssetId{
AssetId: assetId,
},
},
PeerPubKey: peerPubkey,
PaymentMaxAmt: uint64(paymentMaxAmt),
Expiry: uint64(expiry),
TimeoutSeconds: c.rfqTimeoutSeconds,
})
if err != nil {
return nil, err
}
if rfq.GetInvalidQuote() != nil {
return nil, fmt.Errorf("invalid RFQ: %v", rfq.GetInvalidQuote())
}
if rfq.GetRejectedQuote() != nil {
return nil, fmt.Errorf("rejected RFQ: %v",
rfq.GetRejectedQuote())
}
if rfq.GetAcceptedQuote() != nil {
return rfq.GetAcceptedQuote(), nil
}
return nil, fmt.Errorf("no accepted quote")
}
// GetAssetName returns the human-readable name of the asset.
func (c *TapdClient) GetAssetName(ctx context.Context,
assetId []byte) (string, error) {
assetIdStr := hex.EncodeToString(assetId)
if name, ok := c.getCachedAssetName(assetIdStr); ok {
return name, nil
}
assetStats, err := c.UniverseClient.QueryAssetStats(
ctx, &universerpc.AssetStatsQuery{
AssetIdFilter: assetId,
},
)
if err != nil {
return "", err
}
if len(assetStats.AssetStats) == 0 {
return "", fmt.Errorf("asset not found")
}
var assetName string
// If the asset belongs to a group, return the group name.
if assetStats.AssetStats[0].GroupAnchor != nil {
assetName = assetStats.AssetStats[0].GroupAnchor.AssetName
} else {
assetName = assetStats.AssetStats[0].Asset.AssetName
}
c.cacheAssetName(assetIdStr, assetName)
return assetName, nil
}
// getCachedAssetName returns an asset name from the cache.
func (c *TapdClient) getCachedAssetName(assetID string) (string, bool) {
c.assetNameMutex.RLock()
defer c.assetNameMutex.RUnlock()
name, ok := c.assetNameCache[assetID]
return name, ok
}
// cacheAssetName adds an asset name to the cache.
func (c *TapdClient) cacheAssetName(assetID, name string) {
c.assetNameMutex.Lock()
defer c.assetNameMutex.Unlock()
c.assetNameCache[assetID] = name
}
// GetAssetPrice returns the price of an asset in satoshis. NOTE: this currently
// uses the rfq process for the asset price. A future implementation should
// use a price oracle to not spam a peer.
func (c *TapdClient) GetAssetPrice(ctx context.Context, assetID string,
peerPubkey []byte, assetAmt uint64, paymentMaxAmt btcutil.Amount) (
btcutil.Amount, error) {
// We'll allow a short rfq expiry as we'll only use this rfq to
// gauge a price.
rfqExpiry := time.Now().Add(time.Minute).Unix()
msatAmt := lnwire.NewMSatFromSatoshis(paymentMaxAmt)
// First we'll rfq a random peer for the asset.
rfq, err := c.RfqClient.AddAssetSellOrder(
ctx, &rfqrpc.AddAssetSellOrderRequest{
AssetSpecifier: &rfqrpc.AssetSpecifier{
Id: &rfqrpc.AssetSpecifier_AssetIdStr{
AssetIdStr: assetID,
},
},
PaymentMaxAmt: uint64(msatAmt),
Expiry: uint64(rfqExpiry),
TimeoutSeconds: c.rfqTimeoutSeconds,
PeerPubKey: peerPubkey,
})
if err != nil {
return 0, err
}
if rfq == nil {
return 0, fmt.Errorf("no RFQ response")
}
if rfq.GetInvalidQuote() != nil {
return 0, fmt.Errorf("peer %v sent an invalid quote response %v for "+
"asset %v", peerPubkey, rfq.GetInvalidQuote(), assetID)
}
if rfq.GetRejectedQuote() != nil {
return 0, fmt.Errorf("peer %v rejected the quote request for "+
"asset %v, %v", peerPubkey, assetID, rfq.GetRejectedQuote())
}
acceptedRes := rfq.GetAcceptedQuote()
if acceptedRes == nil {
return 0, fmt.Errorf("no accepted quote")
}
// We'll use the accepted quote to calculate the price.
return getSatsFromAssetAmt(assetAmt, acceptedRes.BidAssetRate)
}
// getSatsFromAssetAmt returns the amount in satoshis for the given asset amount
// and asset rate.
func getSatsFromAssetAmt(assetAmt uint64, assetRate *rfqrpc.FixedPoint) (
btcutil.Amount, error) {
rateFP, err := unmarshalAssetRate(assetRate)
if err != nil {
return 0, fmt.Errorf("cannot unmarshal asset rate: %w", err)
}
assetUnits := rfqmath.NewBigIntFixedPoint(assetAmt, 0)
msatAmt := rfqmath.UnitsToMilliSatoshi(assetUnits, *rateFP)
return msatAmt.ToSatoshis(), nil
}
// unmarshalAssetRate validates and converts an RPC asset rate to the fixed
// point representation used for RFQ arithmetic.
func unmarshalAssetRate(assetRate *rfqrpc.FixedPoint) (
*rfqmath.BigIntFixedPoint, error) {
if assetRate == nil {
return nil, fmt.Errorf("asset rate cannot be nil")
}
if assetRate.Scale > math.MaxUint8 {
return nil, fmt.Errorf("scale value overflow: %v", assetRate.Scale)
}
coefficient, ok := new(big.Int).SetString(assetRate.Coefficient, 10)
if !ok {
return nil, fmt.Errorf("invalid asset rate coefficient: %q",
assetRate.Coefficient)
}
if coefficient.Sign() <= 0 {
return nil, fmt.Errorf("asset rate coefficient must be positive")
}
return &rfqmath.BigIntFixedPoint{
Coefficient: rfqmath.NewBigInt(coefficient),
Scale: uint8(assetRate.Scale),
}, nil
}
// getPaymentMaxAmount returns the milisat amount we are willing to pay for the
// payment.
func getPaymentMaxAmount(satAmount btcutil.Amount, feeLimitMultiplier float64) (
lnwire.MilliSatoshi, error) {
if satAmount == 0 {
return 0, fmt.Errorf("satAmount cannot be zero")
}
if feeLimitMultiplier < 1 {
return 0, fmt.Errorf("feeLimitMultiplier must be at least 1")
}
// paymentMaxAmt is the maximum amount we are willing to pay for the
// payment.
// E.g. on a 250k sats payment we'll multiply the sat amount by 1.2.
// The resulting maximum amount we're willing to pay is 300k sats.
// The response asset amount will be for those 300k sats.
return lnrpc.UnmarshallAmt(
int64(satAmount.MulF64(feeLimitMultiplier)), 0,
)
}
// getRfqTimeoutSeconds converts the configured RFQ timeout to the whole
// seconds accepted by tapd. Fractional seconds are rounded up so tapd's
// timeout is never shorter than the configured duration.
func getRfqTimeoutSeconds(timeout time.Duration) (uint32, error) {
if timeout <= 0 {
return 0, fmt.Errorf("RFQ timeout must be greater than zero")
}
seconds := timeout / time.Second
if timeout%time.Second != 0 {
seconds++
}
if seconds > time.Duration(math.MaxUint32) {
return 0, fmt.Errorf("RFQ timeout exceeds maximum of %v seconds",
uint64(math.MaxUint32))
}
return uint32(seconds), nil
}
func getClientConn(config *TapdConfig) (*grpc.ClientConn, error) {
// Load the specified TLS certificate and build transport credentials.
creds, err := credentials.NewClientTLSFromFile(config.TLSPath, "")
if err != nil {
return nil, err
}
// Load the specified macaroon file.
macBytes, err := os.ReadFile(config.MacaroonPath)
if err != nil {
return nil, err
}
mac := &macaroon.Macaroon{}
if err := mac.UnmarshalBinary(macBytes); err != nil {
return nil, err
}
macaroon, err := macaroons.NewMacaroonCredential(mac)
if err != nil {
return nil, err
}
// Create the DialOptions with the macaroon credentials.
opts := []grpc.DialOption{
grpc.WithTransportCredentials(creds),
grpc.WithPerRPCCredentials(macaroon),
grpc.WithDefaultCallOptions(maxMsgRecvSize),
}
// Dial the gRPC server.
conn, err := grpc.NewClient(config.Host, opts...)
if err != nil {
return nil, err
}
return conn, nil
}