mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-13 12:33:03 +02:00
Include https://github.com/lightninglabs/lndclient/pull/280 multi: migrate to btcd v2 modules + add WalletKit.SubmitPackage Migrate Loop imports and update Aperture and Taproot Assets to compatible revisions so this commit remains green on its own.
451 lines
11 KiB
Go
451 lines
11 KiB
Go
package assets
|
|
|
|
import (
|
|
"context"
|
|
"encoding/hex"
|
|
"encoding/pem"
|
|
"math"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/btcsuite/btcd/btcutil/v2"
|
|
"github.com/lightninglabs/taproot-assets/taprpc/rfqrpc"
|
|
"github.com/lightninglabs/taproot-assets/taprpc/universerpc"
|
|
"github.com/lightningnetwork/lnd/lnwire"
|
|
"github.com/stretchr/testify/require"
|
|
"google.golang.org/grpc"
|
|
"gopkg.in/macaroon.v2"
|
|
)
|
|
|
|
type blockingUniverseClient struct {
|
|
universerpc.UniverseClient
|
|
|
|
queryStarted chan struct{}
|
|
releaseQuery chan struct{}
|
|
}
|
|
|
|
func (b *blockingUniverseClient) QueryAssetStats(context.Context,
|
|
*universerpc.AssetStatsQuery, ...grpc.CallOption) (
|
|
*universerpc.UniverseAssetStats, error) {
|
|
|
|
close(b.queryStarted)
|
|
<-b.releaseQuery
|
|
|
|
return &universerpc.UniverseAssetStats{
|
|
AssetStats: []*universerpc.AssetStatsSnapshot{
|
|
{
|
|
Asset: &universerpc.AssetStatsAsset{
|
|
AssetName: "queried asset",
|
|
},
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
type staticRfqClient struct {
|
|
rfqrpc.RfqClient
|
|
|
|
response *rfqrpc.AddAssetSellOrderResponse
|
|
}
|
|
|
|
func (s *staticRfqClient) AddAssetSellOrder(context.Context,
|
|
*rfqrpc.AddAssetSellOrderRequest, ...grpc.CallOption) (
|
|
*rfqrpc.AddAssetSellOrderResponse, error) {
|
|
|
|
return s.response, nil
|
|
}
|
|
|
|
// TestDefaultTapdConfig tests that the default tapd connection paths match
|
|
// tapd's mainnet defaults.
|
|
func TestDefaultTapdConfig(t *testing.T) {
|
|
defaultTapdDir := btcutil.AppDataDir("tapd", false)
|
|
config := DefaultTapdConfig()
|
|
|
|
require.Equal(t, filepath.Join(
|
|
defaultTapdDir, "data", "mainnet", "admin.macaroon",
|
|
), config.MacaroonPath)
|
|
require.Equal(
|
|
t, filepath.Join(defaultTapdDir, "tls.cert"), config.TLSPath,
|
|
)
|
|
}
|
|
|
|
// TestTapdConfigClientConn tests that the default tapd file layout can be used
|
|
// to construct a client connection.
|
|
func TestTapdConfigClientConn(t *testing.T) {
|
|
// Use an isolated tapd root so the test never reads from or writes to a
|
|
// user's real tapd data directory.
|
|
defaultTapdDir := t.TempDir()
|
|
network := "regtest"
|
|
macaroonPath := filepath.Join(
|
|
defaultTapdDir, "data", network, "admin.macaroon",
|
|
)
|
|
require.NoError(t, os.MkdirAll(filepath.Dir(macaroonPath), 0o700))
|
|
|
|
// NewTapdClient parses the configured TLS certificate before creating
|
|
// its gRPC client. An httptest server provides a valid certificate
|
|
// without requiring a running tapd instance.
|
|
tlsServer := httptest.NewTLSServer(http.NotFoundHandler())
|
|
t.Cleanup(tlsServer.Close)
|
|
cert := tlsServer.Certificate()
|
|
certBytes := pem.EncodeToMemory(&pem.Block{
|
|
Type: "CERTIFICATE", Bytes: cert.Raw,
|
|
})
|
|
require.NoError(t, os.WriteFile(
|
|
filepath.Join(defaultTapdDir, "tls.cert"), certBytes, 0o600,
|
|
))
|
|
|
|
// Store a valid serialized macaroon at tapd's production path. This
|
|
// ensures connection setup tests the path itself rather than failing on
|
|
// malformed credentials.
|
|
mac, err := macaroon.New(
|
|
[]byte("root-key"), []byte("id"), "tapd",
|
|
macaroon.LatestVersion,
|
|
)
|
|
require.NoError(t, err)
|
|
macBytes, err := mac.MarshalBinary()
|
|
require.NoError(t, err)
|
|
require.NoError(t, os.WriteFile(macaroonPath, macBytes, 0o600))
|
|
|
|
// grpc.NewClient connects lazily, so constructing the tapd client verifies
|
|
// that both credentials can be loaded and parsed without needing a live
|
|
// tapd server.
|
|
config := DefaultTapdConfigForNetwork(defaultTapdDir, network)
|
|
client, err := NewTapdClient(config)
|
|
require.NoError(t, err)
|
|
t.Cleanup(func() {
|
|
require.NoError(t, client.cc.Close())
|
|
})
|
|
|
|
require.Equal(t, macaroonPath, config.MacaroonPath)
|
|
require.Equal(
|
|
t, filepath.Join(defaultTapdDir, "tls.cert"), config.TLSPath,
|
|
)
|
|
}
|
|
|
|
// TestGetAssetNameCachedLookupNotBlocked verifies that a slow universe query
|
|
// for one asset does not prevent another caller from reading a cached name.
|
|
func TestGetAssetNameCachedLookupNotBlocked(t *testing.T) {
|
|
const cachedName = "cached asset"
|
|
|
|
cachedAssetID := []byte{1}
|
|
queryStarted := make(chan struct{})
|
|
releaseQuery := make(chan struct{})
|
|
client := &TapdClient{
|
|
UniverseClient: &blockingUniverseClient{
|
|
queryStarted: queryStarted,
|
|
releaseQuery: releaseQuery,
|
|
},
|
|
assetNameCache: map[string]string{
|
|
hex.EncodeToString(cachedAssetID): cachedName,
|
|
},
|
|
}
|
|
|
|
queryResult := make(chan error, 1)
|
|
go func() {
|
|
_, err := client.GetAssetName(context.Background(), []byte{2})
|
|
queryResult <- err
|
|
}()
|
|
|
|
select {
|
|
case <-queryStarted:
|
|
case <-time.After(time.Second):
|
|
t.Fatal("universe query did not start")
|
|
}
|
|
|
|
type nameResult struct {
|
|
name string
|
|
err error
|
|
}
|
|
cachedResult := make(chan nameResult, 1)
|
|
go func() {
|
|
name, err := client.GetAssetName(
|
|
context.Background(), cachedAssetID,
|
|
)
|
|
cachedResult <- nameResult{name: name, err: err}
|
|
}()
|
|
|
|
select {
|
|
case result := <-cachedResult:
|
|
require.NoError(t, result.err)
|
|
require.Equal(t, cachedName, result.name)
|
|
case <-time.After(time.Second):
|
|
close(releaseQuery)
|
|
t.Fatal("cached lookup blocked behind universe query")
|
|
}
|
|
|
|
close(releaseQuery)
|
|
require.NoError(t, <-queryResult)
|
|
}
|
|
|
|
// TestGetRfqForAssetValidatesRate verifies that malformed accepted quote rates
|
|
// are rejected before they reach downstream RFQ arithmetic.
|
|
func TestGetRfqForAssetValidatesRate(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
assetRate *rfqrpc.FixedPoint
|
|
expectError bool
|
|
}{
|
|
{
|
|
name: "valid",
|
|
assetRate: &rfqrpc.FixedPoint{
|
|
Coefficient: "100000", Scale: 0,
|
|
},
|
|
},
|
|
{
|
|
name: "nil",
|
|
assetRate: nil,
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "malformed coefficient",
|
|
assetRate: &rfqrpc.FixedPoint{
|
|
Coefficient: "not-a-number", Scale: 0,
|
|
},
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "zero coefficient",
|
|
assetRate: &rfqrpc.FixedPoint{
|
|
Coefficient: "0", Scale: 0,
|
|
},
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "negative coefficient",
|
|
assetRate: &rfqrpc.FixedPoint{
|
|
Coefficient: "-1", Scale: 0,
|
|
},
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "scale overflow",
|
|
assetRate: &rfqrpc.FixedPoint{
|
|
Coefficient: "1", Scale: 256,
|
|
},
|
|
expectError: true,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
acceptedQuote := &rfqrpc.PeerAcceptedSellQuote{
|
|
BidAssetRate: test.assetRate,
|
|
}
|
|
acceptedResponse :=
|
|
&rfqrpc.AddAssetSellOrderResponse_AcceptedQuote{
|
|
AcceptedQuote: acceptedQuote,
|
|
}
|
|
client := &TapdClient{
|
|
RfqClient: &staticRfqClient{
|
|
response: &rfqrpc.AddAssetSellOrderResponse{
|
|
Response: acceptedResponse,
|
|
},
|
|
},
|
|
rfqTimeoutSeconds: 60,
|
|
}
|
|
|
|
quote, err := client.GetRfqForAsset(
|
|
context.Background(), 1000, []byte{1}, []byte{2},
|
|
time.Now().Add(time.Minute).Unix(), 1,
|
|
)
|
|
if test.expectError {
|
|
require.Error(t, err)
|
|
require.Nil(t, quote)
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
require.Same(t, acceptedQuote, quote)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetPaymentMaxAmount(t *testing.T) {
|
|
tests := []struct {
|
|
satAmount btcutil.Amount
|
|
feeLimitMultiplier float64
|
|
expectedAmount lnwire.MilliSatoshi
|
|
expectError bool
|
|
}{
|
|
{
|
|
satAmount: btcutil.Amount(250000),
|
|
feeLimitMultiplier: 1.2,
|
|
expectedAmount: lnwire.MilliSatoshi(300000000),
|
|
expectError: false,
|
|
},
|
|
{
|
|
satAmount: btcutil.Amount(100000),
|
|
feeLimitMultiplier: 1.5,
|
|
expectedAmount: lnwire.MilliSatoshi(150000000),
|
|
expectError: false,
|
|
},
|
|
{
|
|
satAmount: btcutil.Amount(50000),
|
|
feeLimitMultiplier: 2.0,
|
|
expectedAmount: lnwire.MilliSatoshi(100000000),
|
|
expectError: false,
|
|
},
|
|
{
|
|
satAmount: btcutil.Amount(0),
|
|
feeLimitMultiplier: 1.2,
|
|
expectedAmount: lnwire.MilliSatoshi(0),
|
|
expectError: true,
|
|
},
|
|
{
|
|
satAmount: btcutil.Amount(250000),
|
|
feeLimitMultiplier: 0.8,
|
|
expectedAmount: lnwire.MilliSatoshi(0),
|
|
expectError: true,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
result, err := getPaymentMaxAmount(
|
|
test.satAmount, test.feeLimitMultiplier,
|
|
)
|
|
if test.expectError {
|
|
if err == nil {
|
|
t.Fatalf("expected error but got none")
|
|
}
|
|
} else {
|
|
if err != nil {
|
|
t.Fatalf("unexpected error: %v", err)
|
|
}
|
|
if result != test.expectedAmount {
|
|
t.Fatalf("expected %v, got %v",
|
|
test.expectedAmount, result)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestGetRfqTimeoutSeconds verifies that configured durations are safely
|
|
// converted to tapd's whole-second timeout field.
|
|
func TestGetRfqTimeoutSeconds(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
timeout time.Duration
|
|
expectedSeconds uint32
|
|
expectError bool
|
|
}{
|
|
{
|
|
name: "whole seconds",
|
|
timeout: 60 * time.Second,
|
|
expectedSeconds: 60,
|
|
},
|
|
{
|
|
name: "sub-second rounded up",
|
|
timeout: time.Millisecond,
|
|
expectedSeconds: 1,
|
|
},
|
|
{
|
|
name: "fractional second rounded up",
|
|
timeout: time.Second + time.Nanosecond,
|
|
expectedSeconds: 2,
|
|
},
|
|
{
|
|
name: "zero",
|
|
timeout: 0,
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "negative",
|
|
timeout: -time.Second,
|
|
expectError: true,
|
|
},
|
|
{
|
|
name: "overflow",
|
|
timeout: time.Duration(math.MaxUint32)*time.Second +
|
|
time.Nanosecond,
|
|
expectError: true,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
t.Run(test.name, func(t *testing.T) {
|
|
seconds, err := getRfqTimeoutSeconds(test.timeout)
|
|
if test.expectError {
|
|
require.Error(t, err)
|
|
return
|
|
}
|
|
|
|
require.NoError(t, err)
|
|
require.Equal(t, test.expectedSeconds, seconds)
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetSatsFromAssetAmt(t *testing.T) {
|
|
tests := []struct {
|
|
assetAmt uint64
|
|
assetRate *rfqrpc.FixedPoint
|
|
expected btcutil.Amount
|
|
expectError bool
|
|
}{
|
|
{
|
|
assetAmt: 1000,
|
|
assetRate: &rfqrpc.FixedPoint{Coefficient: "100000", Scale: 0},
|
|
expected: btcutil.Amount(1000000),
|
|
expectError: false,
|
|
},
|
|
{
|
|
assetAmt: 500000,
|
|
assetRate: &rfqrpc.FixedPoint{Coefficient: "200000000", Scale: 0},
|
|
expected: btcutil.Amount(250000),
|
|
expectError: false,
|
|
},
|
|
{
|
|
assetAmt: 0,
|
|
assetRate: &rfqrpc.FixedPoint{Coefficient: "100000000", Scale: 0},
|
|
expected: btcutil.Amount(0),
|
|
expectError: false,
|
|
},
|
|
{
|
|
assetAmt: 1000,
|
|
assetRate: nil,
|
|
expectError: true,
|
|
},
|
|
{
|
|
assetAmt: 1000,
|
|
assetRate: &rfqrpc.FixedPoint{
|
|
Coefficient: "not-a-number", Scale: 0,
|
|
},
|
|
expectError: true,
|
|
},
|
|
{
|
|
assetAmt: 1000,
|
|
assetRate: &rfqrpc.FixedPoint{
|
|
Coefficient: "0", Scale: 0,
|
|
},
|
|
expectError: true,
|
|
},
|
|
{
|
|
assetAmt: 1000,
|
|
assetRate: &rfqrpc.FixedPoint{
|
|
Coefficient: "-1", Scale: 0,
|
|
},
|
|
expectError: true,
|
|
},
|
|
{
|
|
assetAmt: 1000,
|
|
assetRate: &rfqrpc.FixedPoint{
|
|
Coefficient: "1", Scale: 256,
|
|
},
|
|
expectError: true,
|
|
},
|
|
}
|
|
|
|
for _, test := range tests {
|
|
result, err := getSatsFromAssetAmt(test.assetAmt, test.assetRate)
|
|
if test.expectError {
|
|
require.NotNil(t, err)
|
|
} else {
|
|
require.Nil(t, err)
|
|
require.Equal(t, test.expected, result)
|
|
}
|
|
}
|
|
}
|