github.com/status-im/status-go@v1.1.0/services/wallet/thirdparty/opensea/client_v2.go (about)

     1  package opensea
     2  
     3  import (
     4  	"context"
     5  	"encoding/json"
     6  	"fmt"
     7  	"net/url"
     8  	"strconv"
     9  	"strings"
    10  
    11  	"github.com/ethereum/go-ethereum/common"
    12  	"github.com/ethereum/go-ethereum/log"
    13  
    14  	walletCommon "github.com/status-im/status-go/services/wallet/common"
    15  	"github.com/status-im/status-go/services/wallet/connection"
    16  	"github.com/status-im/status-go/services/wallet/thirdparty"
    17  )
    18  
    19  const assetLimitV2 = 50
    20  
    21  func getV2BaseURL(chainID walletCommon.ChainID) (string, error) {
    22  	switch uint64(chainID) {
    23  	case walletCommon.EthereumMainnet, walletCommon.ArbitrumMainnet, walletCommon.OptimismMainnet:
    24  		return "https://api.opensea.io/v2", nil
    25  	case walletCommon.EthereumSepolia, walletCommon.ArbitrumSepolia, walletCommon.OptimismSepolia:
    26  		return "https://testnets-api.opensea.io/v2", nil
    27  	}
    28  
    29  	return "", thirdparty.ErrChainIDNotSupported
    30  }
    31  
    32  func (o *ClientV2) ID() string {
    33  	return OpenseaV2ID
    34  }
    35  
    36  func (o *ClientV2) IsChainSupported(chainID walletCommon.ChainID) bool {
    37  	_, err := getV2BaseURL(chainID)
    38  	return err == nil
    39  }
    40  
    41  func (o *ClientV2) IsConnected() bool {
    42  	return o.connectionStatus.IsConnected()
    43  }
    44  
    45  func getV2URL(chainID walletCommon.ChainID, path string) (string, error) {
    46  	baseURL, err := getV2BaseURL(chainID)
    47  	if err != nil {
    48  		return "", err
    49  	}
    50  
    51  	return fmt.Sprintf("%s/%s", baseURL, path), nil
    52  }
    53  
    54  type ClientV2 struct {
    55  	client           *HTTPClient
    56  	apiKey           string
    57  	connectionStatus *connection.Status
    58  	urlGetter        urlGetter
    59  }
    60  
    61  // new opensea v2 client.
    62  func NewClientV2(apiKey string, httpClient *HTTPClient) *ClientV2 {
    63  	if apiKey == "" {
    64  		log.Warn("OpenseaV2 API key not available")
    65  	}
    66  
    67  	return &ClientV2{
    68  		client:           httpClient,
    69  		apiKey:           apiKey,
    70  		connectionStatus: connection.NewStatus(),
    71  		urlGetter:        getV2URL,
    72  	}
    73  }
    74  
    75  func (o *ClientV2) FetchAllAssetsByOwnerAndContractAddress(ctx context.Context, chainID walletCommon.ChainID, owner common.Address, contractAddresses []common.Address, cursor string, limit int) (*thirdparty.FullCollectibleDataContainer, error) {
    76  	// No dedicated endpoint to filter owned assets by contract address.
    77  	// Will probably be available at some point, for now do the filtering ourselves.
    78  	assets := new(thirdparty.FullCollectibleDataContainer)
    79  
    80  	// Build map for more efficient contract address check
    81  	contractHashMap := make(map[string]bool)
    82  	for _, contractAddress := range contractAddresses {
    83  		contractID := thirdparty.ContractID{
    84  			ChainID: chainID,
    85  			Address: contractAddress,
    86  		}
    87  		contractHashMap[contractID.HashKey()] = true
    88  	}
    89  
    90  	assets.PreviousCursor = cursor
    91  	assets.NextCursor = cursor
    92  	assets.Provider = o.ID()
    93  
    94  	for {
    95  		assetsPage, err := o.FetchAllAssetsByOwner(ctx, chainID, owner, assets.NextCursor, assetLimitV2)
    96  		if err != nil {
    97  			return nil, err
    98  		}
    99  
   100  		for _, asset := range assetsPage.Items {
   101  			if contractHashMap[asset.CollectibleData.ID.ContractID.HashKey()] {
   102  				assets.Items = append(assets.Items, asset)
   103  			}
   104  		}
   105  
   106  		assets.NextCursor = assetsPage.NextCursor
   107  
   108  		if assets.NextCursor == "" {
   109  			break
   110  		}
   111  
   112  		if limit > thirdparty.FetchNoLimit && len(assets.Items) >= limit {
   113  			break
   114  		}
   115  	}
   116  
   117  	return assets, nil
   118  }
   119  
   120  func (o *ClientV2) FetchAllAssetsByOwner(ctx context.Context, chainID walletCommon.ChainID, owner common.Address, cursor string, limit int) (*thirdparty.FullCollectibleDataContainer, error) {
   121  	pathParams := []string{
   122  		"chain", chainIDToChainString(chainID),
   123  		"account", owner.String(),
   124  		"nfts",
   125  	}
   126  
   127  	queryParams := url.Values{}
   128  
   129  	return o.fetchAssets(ctx, chainID, pathParams, queryParams, limit, cursor)
   130  }
   131  
   132  func (o *ClientV2) FetchAssetsByCollectibleUniqueID(ctx context.Context, uniqueIDs []thirdparty.CollectibleUniqueID) ([]thirdparty.FullCollectibleData, error) {
   133  	return o.fetchDetailedAssets(ctx, uniqueIDs)
   134  }
   135  
   136  func (o *ClientV2) FetchCollectionSocials(ctx context.Context, contractID thirdparty.ContractID) (*thirdparty.CollectionSocials, error) {
   137  	// we dont want to use opensea as any small number of requests can also lead to throttling
   138  	return nil, thirdparty.ErrEndpointNotSupported
   139  }
   140  
   141  func (o *ClientV2) fetchAssets(ctx context.Context, chainID walletCommon.ChainID, pathParams []string, queryParams url.Values, limit int, cursor string) (*thirdparty.FullCollectibleDataContainer, error) {
   142  	assets := new(thirdparty.FullCollectibleDataContainer)
   143  
   144  	tmpLimit := assetLimitV2
   145  	if limit > thirdparty.FetchNoLimit && limit < tmpLimit {
   146  		tmpLimit = limit
   147  	}
   148  	queryParams["limit"] = []string{strconv.Itoa(tmpLimit)}
   149  
   150  	assets.PreviousCursor = cursor
   151  	if cursor != "" {
   152  		queryParams["next"] = []string{cursor}
   153  	}
   154  	assets.Provider = o.ID()
   155  
   156  	for {
   157  		path := fmt.Sprintf("%s?%s", strings.Join(pathParams, "/"), queryParams.Encode())
   158  		url, err := o.urlGetter(chainID, path)
   159  		if err != nil {
   160  			return nil, err
   161  		}
   162  
   163  		body, err := o.client.doGetRequest(ctx, url, o.apiKey)
   164  		if err != nil {
   165  			if ctx.Err() == nil {
   166  				o.connectionStatus.SetIsConnected(false)
   167  			}
   168  			return nil, err
   169  		}
   170  		o.connectionStatus.SetIsConnected(true)
   171  
   172  		// If body is empty, it means the account has no collectibles for this chain.
   173  		// (Workaround implemented in http_client.go)
   174  		if body == nil {
   175  			assets.NextCursor = ""
   176  			break
   177  		}
   178  
   179  		// if Json is not returned there must be an error
   180  		if !json.Valid(body) {
   181  			return nil, fmt.Errorf("invalid json: %s", string(body))
   182  		}
   183  
   184  		container := NFTContainer{}
   185  		err = json.Unmarshal(body, &container)
   186  		if err != nil {
   187  			return nil, err
   188  		}
   189  
   190  		for _, asset := range container.NFTs {
   191  			assets.Items = append(assets.Items, asset.toCommon(chainID))
   192  		}
   193  		assets.NextCursor = container.NextCursor
   194  
   195  		if assets.NextCursor == "" {
   196  			break
   197  		}
   198  
   199  		queryParams["next"] = []string{assets.NextCursor}
   200  
   201  		if limit > thirdparty.FetchNoLimit && len(assets.Items) >= limit {
   202  			break
   203  		}
   204  	}
   205  
   206  	return assets, nil
   207  }
   208  
   209  func (o *ClientV2) fetchDetailedAssets(ctx context.Context, uniqueIDs []thirdparty.CollectibleUniqueID) ([]thirdparty.FullCollectibleData, error) {
   210  	assets := make([]thirdparty.FullCollectibleData, 0, len(uniqueIDs))
   211  
   212  	for _, id := range uniqueIDs {
   213  		path := fmt.Sprintf("chain/%s/contract/%s/nfts/%s", chainIDToChainString(id.ContractID.ChainID), id.ContractID.Address.String(), id.TokenID.String())
   214  		url, err := o.urlGetter(id.ContractID.ChainID, path)
   215  		if err != nil {
   216  			return nil, err
   217  		}
   218  
   219  		body, err := o.client.doGetRequest(ctx, url, o.apiKey)
   220  		if err != nil {
   221  			if ctx.Err() == nil {
   222  				o.connectionStatus.SetIsConnected(false)
   223  			}
   224  			return nil, err
   225  		}
   226  		o.connectionStatus.SetIsConnected(true)
   227  
   228  		// if Json is not returned there must be an error
   229  		if !json.Valid(body) {
   230  			return nil, fmt.Errorf("invalid json: %s", string(body))
   231  		}
   232  
   233  		nftContainer := DetailedNFTContainer{}
   234  		err = json.Unmarshal(body, &nftContainer)
   235  		if err != nil {
   236  			return nil, err
   237  		}
   238  
   239  		assets = append(assets, nftContainer.NFT.toCommon(id.ContractID.ChainID))
   240  	}
   241  
   242  	return assets, nil
   243  }
   244  
   245  func (o *ClientV2) fetchContractDataByContractID(ctx context.Context, id thirdparty.ContractID) (*ContractData, error) {
   246  	path := fmt.Sprintf("chain/%s/contract/%s", chainIDToChainString(id.ChainID), id.Address.String())
   247  	url, err := o.urlGetter(id.ChainID, path)
   248  	if err != nil {
   249  		return nil, err
   250  	}
   251  
   252  	body, err := o.client.doGetRequest(ctx, url, o.apiKey)
   253  	if err != nil {
   254  		if ctx.Err() == nil {
   255  			o.connectionStatus.SetIsConnected(false)
   256  		}
   257  		return nil, err
   258  	}
   259  	o.connectionStatus.SetIsConnected(true)
   260  
   261  	// if Json is not returned there must be an error
   262  	if !json.Valid(body) {
   263  		return nil, fmt.Errorf("invalid json: %s", string(body))
   264  	}
   265  
   266  	contract := ContractData{}
   267  	err = json.Unmarshal(body, &contract)
   268  	if err != nil {
   269  		return nil, err
   270  	}
   271  
   272  	return &contract, nil
   273  }
   274  
   275  func (o *ClientV2) fetchCollectionDataBySlug(ctx context.Context, chainID walletCommon.ChainID, slug string) (*CollectionData, error) {
   276  	path := fmt.Sprintf("collections/%s", slug)
   277  	url, err := o.urlGetter(chainID, path)
   278  	if err != nil {
   279  		return nil, err
   280  	}
   281  
   282  	body, err := o.client.doGetRequest(ctx, url, o.apiKey)
   283  	if err != nil {
   284  		if ctx.Err() == nil {
   285  			o.connectionStatus.SetIsConnected(false)
   286  		}
   287  		return nil, err
   288  	}
   289  	o.connectionStatus.SetIsConnected(true)
   290  
   291  	// if Json is not returned there must be an error
   292  	if !json.Valid(body) {
   293  		return nil, fmt.Errorf("invalid json: %s", string(body))
   294  	}
   295  
   296  	collection := CollectionData{}
   297  	err = json.Unmarshal(body, &collection)
   298  	if err != nil {
   299  		return nil, err
   300  	}
   301  
   302  	return &collection, nil
   303  }
   304  
   305  func (o *ClientV2) FetchCollectionsDataByContractID(ctx context.Context, contractIDs []thirdparty.ContractID) ([]thirdparty.CollectionData, error) {
   306  	ret := make([]thirdparty.CollectionData, 0, len(contractIDs))
   307  
   308  	for _, id := range contractIDs {
   309  		contractData, err := o.fetchContractDataByContractID(ctx, id)
   310  		if err != nil {
   311  			return nil, err
   312  		}
   313  
   314  		if contractData == nil || contractData.Collection == "" {
   315  			continue
   316  		}
   317  
   318  		collectionData, err := o.fetchCollectionDataBySlug(ctx, id.ChainID, contractData.Collection)
   319  		if err != nil {
   320  			return nil, err
   321  		}
   322  
   323  		ret = append(ret, collectionData.toCommon(id, contractData.ContractStandard))
   324  	}
   325  
   326  	return ret, nil
   327  }