diff --git a/itest/loadtest/config.go b/itest/loadtest/config.go index 20570c273..ac133d418 100644 --- a/itest/loadtest/config.go +++ b/itest/loadtest/config.go @@ -4,6 +4,7 @@ import ( "time" "github.com/jessevdk/go-flags" + "github.com/lightninglabs/taproot-assets/taprpc" ) const ( @@ -58,9 +59,21 @@ type Config struct { // Bitcoin is the configuration for the bitcoin backend. Bitcoin *BitcoinConfig `group:"bitcoin" namespace:"bitcoin" long:"bitcoin" description:"bitcoin client configuration"` - // BatchSize is the number of assets to mint in a single batch. This is only - // relevant for some test cases. - BatchSize int `long:"batch-size" description:"the number of assets to mint in a single batch"` + // BatchSize is the number of assets to mint in a single batch. This is + // only relevant for the mint test. + BatchSize int `long:"mint-test-batch-size" description:"the number of assets to mint in a single batch; only relevant for the mint test"` + + // NumSends is the number of asset sends to perform. This is only + // relevant for the send test. + NumSends int `long:"send-test-num-sends" description:"the number of send operations to perform; only relevant for the send test"` + + // NumAssets is the number of assets to send in each send operation. + // This is only relevant for the send test. + NumAssets uint64 `long:"send-test-num-assets" description:"the number of assets to send in each send operation; only relevant for the send test"` + + // SendType is the type of asset to attempt to send. This is only + // relevant for the send test. + SendType taprpc.AssetType `long:"send-test-send-type" description:"the type of asset to attempt to send; only relevant for the send test"` // TestSuiteTimeout is the timeout for the entire test suite. TestSuiteTimeout time.Duration `long:"test-suite-timeout" description:"the timeout for the entire test suite"` @@ -73,7 +86,6 @@ type Config struct { // binary. func DefaultConfig() Config { return Config{ - TestCases: []string{"mint_batch_stress"}, Alice: &User{ Tapd: &TapConfig{ Name: "alice", @@ -85,6 +97,9 @@ func DefaultConfig() Config { }, }, BatchSize: 100, + NumSends: 50, + NumAssets: 1, // We only mint collectibles. + SendType: taprpc.AssetType_COLLECTIBLE, TestSuiteTimeout: defaultSuiteTimeout, TestTimeout: defaultTestTimeout, } @@ -95,21 +110,11 @@ func DefaultConfig() Config { // // The configuration proceeds as follows: // 1. Start with a default config with sane settings -// 2. Pre-parse the command line to check for an alternative config file -// 3. Load configuration file overwriting defaults with any specified options -// 4. Parse CLI options and overwrite/add any specified options +// 2. Load configuration file overwriting defaults with any specified options func LoadConfig() (*Config, error) { - // Pre-parse the command line options to pick up an alternative config - // file. - preCfg := DefaultConfig() - if _, err := flags.Parse(&preCfg); err != nil { - return nil, err - } - - // Next, load any additional configuration options from the file. - cfg := preCfg + // First, load any additional configuration options from the file. + cfg := DefaultConfig() fileParser := flags.NewParser(&cfg, flags.Default) - err := flags.NewIniParser(fileParser).ParseFile(defaultConfigPath) if err != nil { // If it's a parsing related error, then we'll return @@ -120,13 +125,6 @@ func LoadConfig() (*Config, error) { } } - // Finally, parse the remaining command line options again to ensure - // they take precedence. - flagParser := flags.NewParser(&cfg, flags.Default) - if _, err := flagParser.Parse(); err != nil { - return nil, err - } - // Make sure everything we just loaded makes sense. cleanCfg, err := ValidateConfig(cfg) if err != nil { diff --git a/itest/loadtest/load_test.go b/itest/loadtest/load_test.go index 0465fc825..1d32a83b8 100644 --- a/itest/loadtest/load_test.go +++ b/itest/loadtest/load_test.go @@ -9,6 +9,22 @@ import ( "github.com/stretchr/testify/require" ) +type testCase struct { + name string + fn func(t *testing.T, ctx context.Context, cfg *Config) +} + +var loadTestCases = []testCase{ + { + name: "mint", + fn: mintTest, + }, + { + name: "send", + fn: sendTest, + }, +} + // TestPerformance executes the configured performance tests. func TestPerformance(t *testing.T) { cfg, err := LoadConfig() @@ -18,23 +34,44 @@ func TestPerformance(t *testing.T) { ctxt, cancel := context.WithTimeout(ctxb, cfg.TestSuiteTimeout) defer cancel() - for _, testCase := range cfg.TestCases { - execTestCase(t, ctxt, testCase, cfg) - } -} + for _, tc := range loadTestCases { + tc := tc -// execTestCase is the method in charge of executing a single test case. -func execTestCase(t *testing.T, ctx context.Context, testName string, - cfg *Config) { + if !shouldRunCase(tc.name, cfg.TestCases) { + t.Logf("Not running test case '%s' as not configured", + tc.name) - ctxt, cancel := context.WithTimeout(ctx, cfg.TestTimeout) - defer cancel() + continue + } - switch testName { - case "mint_batch_stress": - execMintBatchStressTest(t, ctxt, cfg) + success := t.Run(tc.name, func(tt *testing.T) { + ctxt, cancel := context.WithTimeout( + ctxt, cfg.TestTimeout, + ) + defer cancel() - default: - require.Fail(t, "unknown test case: %v", testName) + tc.fn(t, ctxt, cfg) + }) + if !success { + t.Fatalf("test case %v failed", tc.name) + } } } + +// shouldRunCase returns true if the given test case should be run. This will +// return true if the config file does not specify any test cases. In that case +// we can select the test cases to run using the command line +// (-test.run="TestPerformance/test_case_name") +func shouldRunCase(name string, configuredCases []string) bool { + if len(configuredCases) == 0 { + return true + } + + for _, c := range configuredCases { + if c == name { + return true + } + } + + return false +} diff --git a/itest/loadtest/mint_batch_test.go b/itest/loadtest/mint_batch_test.go index 56311905a..b261718ab 100644 --- a/itest/loadtest/mint_batch_test.go +++ b/itest/loadtest/mint_batch_test.go @@ -11,7 +11,6 @@ import ( "testing" "time" - "github.com/btcsuite/btcd/rpcclient" "github.com/lightninglabs/taproot-assets/fn" "github.com/lightninglabs/taproot-assets/itest" "github.com/lightninglabs/taproot-assets/taprpc" @@ -24,60 +23,27 @@ import ( //go:embed testdata/8k-metadata.hex var imageMetadataHex []byte -// execMintBatchStressTest checks that we are able to mint a batch of assets -// and that other memebers in the federation see the universe updated -// accordingly. -func execMintBatchStressTest(t *testing.T, ctx context.Context, cfg *Config) { - // Create tapd clients. - alice, aliceCleanUp := getTapClient(t, ctx, cfg.Alice.Tapd) - defer aliceCleanUp() - - _, err := alice.GetInfo(ctx, &taprpc.GetInfoRequest{}) - require.NoError(t, err) - - bob, bobCleanUp := getTapClient(t, ctx, cfg.Bob.Tapd) - defer bobCleanUp() - - _, err = bob.GetInfo(ctx, &taprpc.GetInfoRequest{}) - require.NoError(t, err) - - // Create bitcoin client. - bitcoinClient := getBitcoinConn(t, cfg.Bitcoin) - - itest.MineBlocks(t, bitcoinClient, 1, 0) - - // If we fail from this point onward, we might have created a - // transaction that isn't mined yet. To make sure we can run the test - // again, we'll make sure to clean up the mempool by mining a block. - t.Cleanup(func() { - itest.MineBlocks(t, bitcoinClient, 1, 0) - }) +// mintTest checks that we are able to mint a batch of assets and that other +// members in the federation see the universe updated accordingly. +func mintTest(t *testing.T, ctx context.Context, cfg *Config) { + // Start by initializing all our client connections. + alice, bob, bitcoinClient := initClients(t, ctx, cfg) imageMetadataBytes, err := hex.DecodeString( strings.Trim(string(imageMetadataHex), "\n"), ) require.NoError(t, err) - aliceHost := fmt.Sprintf("%s:%d", cfg.Alice.Tapd.Host, - cfg.Alice.Tapd.Port) - - minterTimeout := 10 * time.Minute - mintBatchStressTest( - t, ctx, bitcoinClient, alice, bob, aliceHost, cfg.BatchSize, - imageMetadataBytes, minterTimeout, - ) -} - -func mintBatchStressTest(t *testing.T, ctx context.Context, - bitcoinClient *rpcclient.Client, alice, bob itest.TapdClient, - aliceHost string, batchSize int, imageMetadataBytes []byte, - minterTimeout time.Duration) { - var ( + minterTimeout = cfg.TestTimeout + batchSize = cfg.BatchSize batchReqs = make([]*mintrpc.MintAssetRequest, batchSize) baseName = fmt.Sprintf("jpeg-%d", rand.Int31()) metaPrefixSize = binary.MaxVarintLen16 metadataPrefix = make([]byte, metaPrefixSize) + aliceHost = fmt.Sprintf( + "%s:%d", alice.cfg.Host, alice.cfg.Port, + ) ) // Before we mint a new group, let's first find out how many there diff --git a/itest/loadtest/send_test.go b/itest/loadtest/send_test.go new file mode 100644 index 000000000..b63412d1f --- /dev/null +++ b/itest/loadtest/send_test.go @@ -0,0 +1,129 @@ +package loadtest + +import ( + "context" + prand "math/rand" + "testing" + + "github.com/btcsuite/btcd/rpcclient" + "github.com/lightninglabs/taproot-assets/itest" + "github.com/lightninglabs/taproot-assets/taprpc" + "github.com/lightningnetwork/lnd/lntest/wait" + "github.com/stretchr/testify/require" +) + +var ( + statusDetected = taprpc.AddrEventStatus_ADDR_EVENT_STATUS_TRANSACTION_DETECTED + statusCompleted = taprpc.AddrEventStatus_ADDR_EVENT_STATUS_COMPLETED +) + +// sendTest checks that we are able to send assets between the two nodes. +func sendTest(t *testing.T, ctx context.Context, cfg *Config) { + // Start by initializing all our client connections. + alice, bob, bitcoinClient := initClients(t, ctx, cfg) + + ctxb := context.Background() + ctxt, cancel := context.WithTimeout(ctxb, cfg.TestTimeout) + defer cancel() + + t.Logf("Running send test, sending %d asset(s) of type %v %d times", + cfg.NumAssets, cfg.SendType, cfg.NumSends) + for i := 1; i <= cfg.NumSends; i++ { + send, receive, ok := pickSendNode( + t, ctx, cfg.NumAssets, cfg.SendType, alice, bob, + ) + if !ok { + t.Fatalf("Aborting send test at attempt %d of %d as "+ + "no node has enough balance to send %d "+ + "assets of type %v", i, cfg.NumSends, + cfg.NumAssets, cfg.SendType) + return + } + + sendAssets( + t, ctxt, cfg.NumAssets, cfg.SendType, send, receive, + bitcoinClient, + ) + + t.Logf("Finished %d of %d send operations", i, cfg.NumSends) + } +} + +// sendAsset sends the given number of assets of the given type from the given +// node to the other node. +func sendAssets(t *testing.T, ctx context.Context, numAssets uint64, + assetType taprpc.AssetType, send, receive *rpcClient, + bitcoinClient *rpcclient.Client) { + + // Query the asset we'll be sending, so we can assert some things about + // it later. + sendAsset := send.assetIDWithBalance(t, ctx, numAssets, assetType) + t.Logf("Sending %d asset(s) with ID %x from %v to %v", numAssets, + sendAsset.AssetGenesis.AssetId, send.cfg.Name, receive.cfg.Name) + + // Let's create an address on the receiving node and make sure it's + // created correctly. + addr, err := receive.NewAddr(ctx, &taprpc.NewAddrRequest{ + AssetId: sendAsset.AssetGenesis.AssetId, + Amt: numAssets, + }) + require.NoError(t, err) + itest.AssertAddrCreated(t, receive, sendAsset, addr) + + // Before we send the asset, we record the existing transfers on the + // sending node, so we can easily select the new transfer once it + // appears. + transfersBefore := send.listTransfersSince(t, ctx, nil) + + // Initiate the send now. + _, err = send.SendAsset(ctx, &taprpc.SendAssetRequest{ + TapAddrs: []string{addr.Encoded}, + }) + require.NoError(t, err) + + // Wait for the transfer to appear on the sending node. + require.Eventually(t, func() bool { + newTransfers := send.listTransfersSince(t, ctx, transfersBefore) + return len(newTransfers) == 1 + }, defaultTimeout, wait.PollInterval) + + // And for it to be detected on the receiving node. + itest.AssertAddrEvent(t, receive, addr, 1, statusDetected) + + // Mine a block to confirm the transfer. + itest.MineBlocks(t, bitcoinClient, 1, 1) + + // Now the transfer should go to completed eventually. + itest.AssertAddrEvent(t, receive, addr, 1, statusCompleted) +} + +// pickSendNode picks a node at random, checks whether it has enough assets of +// the given type, and returns it. The second return value is the other node, +// which will be the receiving node. The boolean argument returns true if there +// is a node with sufficient balance. If that is false, the test should be +// skipped. +func pickSendNode(t *testing.T, ctx context.Context, minBalance uint64, + assetType taprpc.AssetType, a, b *rpcClient) (*rpcClient, *rpcClient, + bool) { + + send, receive := a, b + if prand.Intn(1) == 0 { + send, receive = b, a + } + + // Check if the randomly picked send node has enough balance. + if send.assetIDWithBalance(t, ctx, minBalance, assetType) != nil { + return send, receive, true + } + + // If we get here, the send node doesn't have enough balance. We'll try + // the other one. + send, receive = receive, send + if send.assetIDWithBalance(t, ctx, minBalance, assetType) != nil { + return send, receive, true + } + + // None of the nodes have enough balance. We can't run the send test + // currently. + return nil, nil, false +} diff --git a/itest/loadtest/utils.go b/itest/loadtest/utils.go index 5fe40d096..ad75256d8 100644 --- a/itest/loadtest/utils.go +++ b/itest/loadtest/utils.go @@ -1,14 +1,18 @@ package loadtest import ( + "bytes" "context" "crypto/tls" "crypto/x509" + "encoding/hex" "fmt" "os" "testing" + "time" "github.com/btcsuite/btcd/rpcclient" + "github.com/lightninglabs/taproot-assets/itest" "github.com/lightninglabs/taproot-assets/taprpc" "github.com/lightninglabs/taproot-assets/taprpc/assetwalletrpc" "github.com/lightninglabs/taproot-assets/taprpc/mintrpc" @@ -25,17 +29,107 @@ var ( // maxMsgRecvSize is the largest message our client will receive. We // set this to 200MiB atm. maxMsgRecvSize = grpc.MaxCallRecvMsgSize(lnrpc.MaxGrpcMsgSize) + + // defaultTimeout is a timeout that will be used for various wait + // scenarios where no custom timeout value is defined. + defaultTimeout = time.Second * 10 ) type rpcClient struct { + cfg *TapConfig taprpc.TaprootAssetsClient universerpc.UniverseClient mintrpc.MintClient assetwalletrpc.AssetWalletClient } +// assetIDWithBalance returns the asset ID of an asset that has at least the +// given balance. If no such asset is found, nil is returned. +func (r *rpcClient) assetIDWithBalance(t *testing.T, ctx context.Context, + minBalance uint64, assetType taprpc.AssetType) *taprpc.Asset { + + balances, err := r.ListBalances(ctx, &taprpc.ListBalancesRequest{ + GroupBy: &taprpc.ListBalancesRequest_AssetId{ + AssetId: true, + }, + }) + require.NoError(t, err) + + for assetIDHex, balance := range balances.AssetBalances { + if balance.Balance >= minBalance && + balance.AssetType == assetType { + + assetIDBytes, err := hex.DecodeString(assetIDHex) + require.NoError(t, err) + + assets, err := r.ListAssets( + ctx, &taprpc.ListAssetRequest{}, + ) + require.NoError(t, err) + + for _, asset := range assets.Assets { + if bytes.Equal( + asset.AssetGenesis.AssetId, + assetIDBytes, + ) { + + return asset + } + } + } + } + + return nil +} + +// listTransfersSince returns all transfers that have been made since the last +// transfer in the given list. If the list is empty, all transfers are returned. +func (r *rpcClient) listTransfersSince(t *testing.T, ctx context.Context, + existingTransfers []*taprpc.AssetTransfer) []*taprpc.AssetTransfer { + + resp, err := r.ListTransfers(ctx, &taprpc.ListTransfersRequest{}) + require.NoError(t, err) + + if len(existingTransfers) == 0 { + return resp.Transfers + } + + newIndex := len(existingTransfers) + return resp.Transfers[newIndex:] +} + +func initClients(t *testing.T, ctx context.Context, + cfg *Config) (*rpcClient, *rpcClient, *rpcclient.Client) { + + // Create tapd clients. + alice := getTapClient(t, ctx, cfg.Alice.Tapd) + + _, err := alice.GetInfo(ctx, &taprpc.GetInfoRequest{}) + require.NoError(t, err) + + bob := getTapClient(t, ctx, cfg.Bob.Tapd) + + _, err = bob.GetInfo(ctx, &taprpc.GetInfoRequest{}) + require.NoError(t, err) + + // Create bitcoin client. + bitcoinClient := getBitcoinConn(t, cfg.Bitcoin) + + // Test bitcoin client connection by mining a block. + itest.MineBlocks(t, bitcoinClient, 1, 0) + + // If we fail from this point onward, we might have created a + // transaction that isn't mined yet. To make sure we can run the test + // again, we'll make sure to clean up the mempool by mining a block. + t.Cleanup(func() { + itest.MineBlocks(t, bitcoinClient, 1, 0) + }) + + return alice, bob, bitcoinClient +} + func getTapClient(t *testing.T, ctx context.Context, - cfg *TapConfig) (*rpcClient, func()) { + cfg *TapConfig) *rpcClient { creds := credentials.NewTLS(&tls.Config{}) if cfg.TLSPath != "" { @@ -72,7 +166,7 @@ func getTapClient(t *testing.T, ctx context.Context, } svrAddr := fmt.Sprintf("%s:%d", cfg.Host, cfg.Port) - conn, err := grpc.Dial(svrAddr, opts...) + conn, err := grpc.DialContext(ctx, svrAddr, opts...) require.NoError(t, err) assetsClient := taprpc.NewTaprootAssetsClient(conn) @@ -81,17 +175,19 @@ func getTapClient(t *testing.T, ctx context.Context, assetWalletClient := assetwalletrpc.NewAssetWalletClient(conn) client := &rpcClient{ + cfg: cfg, TaprootAssetsClient: assetsClient, UniverseClient: universeClient, MintClient: mintMintClient, AssetWalletClient: assetWalletClient, } - cleanUp := func() { - conn.Close() - } + t.Cleanup(func() { + err := conn.Close() + require.NoError(t, err) + }) - return client, cleanUp + return client } func getBitcoinConn(t *testing.T, cfg *BitcoinConfig) *rpcclient.Client {