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

     1  package rarible
     2  
     3  import (
     4  	"context"
     5  	"encoding/json"
     6  	"fmt"
     7  	"io/ioutil"
     8  	"net/http"
     9  	"net/url"
    10  	"strconv"
    11  	"strings"
    12  	"time"
    13  
    14  	"github.com/cenkalti/backoff/v4"
    15  
    16  	"github.com/ethereum/go-ethereum/common"
    17  	"github.com/ethereum/go-ethereum/log"
    18  
    19  	walletCommon "github.com/status-im/status-go/services/wallet/common"
    20  	"github.com/status-im/status-go/services/wallet/connection"
    21  	"github.com/status-im/status-go/services/wallet/thirdparty"
    22  )
    23  
    24  const ownedNFTLimit = 100
    25  const collectionOwnershipLimit = 50
    26  const nftMetadataBatchLimit = 50
    27  const searchCollectiblesLimit = 1000
    28  const searchCollectionsLimit = 1000
    29  
    30  func (o *Client) ID() string {
    31  	return RaribleID
    32  }
    33  
    34  func (o *Client) IsChainSupported(chainID walletCommon.ChainID) bool {
    35  	_, err := getBaseURL(chainID)
    36  	return err == nil
    37  }
    38  
    39  func (o *Client) IsConnected() bool {
    40  	return o.connectionStatus.IsConnected()
    41  }
    42  
    43  func getBaseURL(chainID walletCommon.ChainID) (string, error) {
    44  	switch uint64(chainID) {
    45  	case walletCommon.EthereumMainnet, walletCommon.ArbitrumMainnet:
    46  		return "https://api.rarible.org", nil
    47  	case walletCommon.EthereumSepolia, walletCommon.ArbitrumSepolia:
    48  		return "https://testnet-api.rarible.org", nil
    49  	}
    50  
    51  	return "", thirdparty.ErrChainIDNotSupported
    52  }
    53  
    54  func getItemBaseURL(chainID walletCommon.ChainID) (string, error) {
    55  	baseURL, err := getBaseURL(chainID)
    56  
    57  	if err != nil {
    58  		return "", err
    59  	}
    60  
    61  	return fmt.Sprintf("%s/v0.1/items", baseURL), nil
    62  }
    63  
    64  func getOwnershipBaseURL(chainID walletCommon.ChainID) (string, error) {
    65  	baseURL, err := getBaseURL(chainID)
    66  
    67  	if err != nil {
    68  		return "", err
    69  	}
    70  
    71  	return fmt.Sprintf("%s/v0.1/ownerships", baseURL), nil
    72  }
    73  
    74  func getCollectionBaseURL(chainID walletCommon.ChainID) (string, error) {
    75  	baseURL, err := getBaseURL(chainID)
    76  
    77  	if err != nil {
    78  		return "", err
    79  	}
    80  
    81  	return fmt.Sprintf("%s/v0.1/collections", baseURL), nil
    82  }
    83  
    84  type Client struct {
    85  	thirdparty.CollectibleContractOwnershipProvider
    86  	client           *http.Client
    87  	mainnetAPIKey    string
    88  	testnetAPIKey    string
    89  	connectionStatus *connection.Status
    90  }
    91  
    92  func NewClient(mainnetAPIKey string, testnetAPIKey string) *Client {
    93  	if mainnetAPIKey == "" {
    94  		log.Warn("Rarible API key not available for Mainnet")
    95  	}
    96  
    97  	if testnetAPIKey == "" {
    98  		log.Warn("Rarible API key not available for Testnet")
    99  	}
   100  
   101  	return &Client{
   102  		client:           &http.Client{Timeout: time.Minute},
   103  		mainnetAPIKey:    mainnetAPIKey,
   104  		testnetAPIKey:    testnetAPIKey,
   105  		connectionStatus: connection.NewStatus(),
   106  	}
   107  }
   108  
   109  func (o *Client) getAPIKey(chainID walletCommon.ChainID) string {
   110  	if chainID.IsMainnet() {
   111  		return o.mainnetAPIKey
   112  	}
   113  	return o.testnetAPIKey
   114  }
   115  
   116  func (o *Client) doQuery(ctx context.Context, url string, apiKey string) (*http.Response, error) {
   117  	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
   118  	if err != nil {
   119  		return nil, err
   120  	}
   121  
   122  	req.Header.Set("content-type", "application/json")
   123  
   124  	return o.doWithRetries(req, apiKey)
   125  }
   126  
   127  func (o *Client) doPostWithJSON(ctx context.Context, url string, payload any, apiKey string) (*http.Response, error) {
   128  	payloadJSON, err := json.Marshal(payload)
   129  	if err != nil {
   130  		return nil, err
   131  	}
   132  
   133  	payloadString := string(payloadJSON)
   134  	payloadReader := strings.NewReader(payloadString)
   135  
   136  	req, err := http.NewRequestWithContext(ctx, "POST", url, payloadReader)
   137  	if err != nil {
   138  		return nil, err
   139  	}
   140  
   141  	req.Header.Add("accept", "application/json")
   142  	req.Header.Add("content-type", "application/json")
   143  
   144  	return o.doWithRetries(req, apiKey)
   145  }
   146  
   147  func (o *Client) doWithRetries(req *http.Request, apiKey string) (*http.Response, error) {
   148  	b := backoff.NewExponentialBackOff()
   149  	b.InitialInterval = time.Millisecond * 1000
   150  	b.RandomizationFactor = 0.1
   151  	b.Multiplier = 1.5
   152  	b.MaxInterval = time.Second * 32
   153  	b.MaxElapsedTime = time.Second * 70
   154  
   155  	b.Reset()
   156  
   157  	req.Header.Set("X-API-KEY", apiKey)
   158  
   159  	op := func() (*http.Response, error) {
   160  		resp, err := o.client.Do(req)
   161  		if err != nil {
   162  			return nil, backoff.Permanent(err)
   163  		}
   164  
   165  		if resp.StatusCode == http.StatusOK {
   166  			return resp, nil
   167  		}
   168  
   169  		err = fmt.Errorf("unsuccessful request: %d %s", resp.StatusCode, http.StatusText(resp.StatusCode))
   170  		if resp.StatusCode == http.StatusTooManyRequests {
   171  			log.Error("doWithRetries failed with http.StatusTooManyRequests", "provider", o.ID(), "elapsed time", b.GetElapsedTime(), "next backoff", b.NextBackOff())
   172  			return nil, err
   173  		}
   174  		return nil, backoff.Permanent(err)
   175  	}
   176  
   177  	return backoff.RetryWithData(op, b)
   178  }
   179  
   180  func (o *Client) FetchCollectibleOwnersByContractAddress(ctx context.Context, chainID walletCommon.ChainID, contractAddress common.Address) (*thirdparty.CollectibleContractOwnership, error) {
   181  	ownership := thirdparty.CollectibleContractOwnership{
   182  		ContractAddress: contractAddress,
   183  		Owners:          make([]thirdparty.CollectibleOwner, 0),
   184  	}
   185  
   186  	queryParams := url.Values{
   187  		"collection": {fmt.Sprintf("%s:%s", chainIDToChainString(chainID), contractAddress.String())},
   188  		"size":       {strconv.Itoa(collectionOwnershipLimit)},
   189  	}
   190  
   191  	baseURL, err := getOwnershipBaseURL(chainID)
   192  
   193  	if err != nil {
   194  		return nil, err
   195  	}
   196  
   197  	for {
   198  		url := fmt.Sprintf("%s/byCollection?%s", baseURL, queryParams.Encode())
   199  
   200  		resp, err := o.doQuery(ctx, url, o.getAPIKey(chainID))
   201  		if err != nil {
   202  			if ctx.Err() == nil {
   203  				o.connectionStatus.SetIsConnected(false)
   204  			}
   205  			return nil, err
   206  		}
   207  		o.connectionStatus.SetIsConnected(true)
   208  
   209  		defer resp.Body.Close()
   210  
   211  		body, err := ioutil.ReadAll(resp.Body)
   212  		if err != nil {
   213  			return nil, err
   214  		}
   215  
   216  		var raribleOwnership ContractOwnershipContainer
   217  		err = json.Unmarshal(body, &raribleOwnership)
   218  		if err != nil {
   219  			return nil, err
   220  		}
   221  
   222  		ownership.Owners = append(ownership.Owners, raribleContractOwnershipsToCommon(raribleOwnership.Ownerships)...)
   223  
   224  		if raribleOwnership.Continuation == "" {
   225  			break
   226  		}
   227  
   228  		queryParams["continuation"] = []string{raribleOwnership.Continuation}
   229  	}
   230  
   231  	return &ownership, nil
   232  }
   233  
   234  func (o *Client) FetchAllAssetsByOwner(ctx context.Context, chainID walletCommon.ChainID, owner common.Address, cursor string, limit int) (*thirdparty.FullCollectibleDataContainer, error) {
   235  	assets := new(thirdparty.FullCollectibleDataContainer)
   236  
   237  	queryParams := url.Values{
   238  		"owner":       {fmt.Sprintf("%s:%s", ethereumString, owner.String())},
   239  		"blockchains": {chainIDToChainString(chainID)},
   240  	}
   241  
   242  	tmpLimit := ownedNFTLimit
   243  	if limit > thirdparty.FetchNoLimit && limit < tmpLimit {
   244  		tmpLimit = limit
   245  	}
   246  	queryParams["size"] = []string{strconv.Itoa(tmpLimit)}
   247  
   248  	if len(cursor) > 0 {
   249  		queryParams["continuation"] = []string{cursor}
   250  		assets.PreviousCursor = cursor
   251  	}
   252  	assets.Provider = o.ID()
   253  
   254  	baseURL, err := getItemBaseURL(chainID)
   255  
   256  	if err != nil {
   257  		return nil, err
   258  	}
   259  
   260  	for {
   261  		url := fmt.Sprintf("%s/byOwner?%s", baseURL, queryParams.Encode())
   262  
   263  		resp, err := o.doQuery(ctx, url, o.getAPIKey(chainID))
   264  		if err != nil {
   265  			if ctx.Err() == nil {
   266  				o.connectionStatus.SetIsConnected(false)
   267  			}
   268  			return nil, err
   269  		}
   270  		o.connectionStatus.SetIsConnected(true)
   271  
   272  		defer resp.Body.Close()
   273  
   274  		body, err := ioutil.ReadAll(resp.Body)
   275  		if err != nil {
   276  			return nil, err
   277  		}
   278  
   279  		// if Json is not returned there must be an error
   280  		if !json.Valid(body) {
   281  			return nil, fmt.Errorf("invalid json: %s", string(body))
   282  		}
   283  
   284  		var container CollectiblesContainer
   285  		err = json.Unmarshal(body, &container)
   286  		if err != nil {
   287  			return nil, err
   288  		}
   289  
   290  		assets.Items = append(assets.Items, raribleToCollectiblesData(container.Collectibles, chainID.IsMainnet())...)
   291  		assets.NextCursor = container.Continuation
   292  
   293  		if len(assets.NextCursor) == 0 {
   294  			break
   295  		}
   296  
   297  		queryParams["continuation"] = []string{assets.NextCursor}
   298  
   299  		if limit != thirdparty.FetchNoLimit && len(assets.Items) >= limit {
   300  			break
   301  		}
   302  	}
   303  
   304  	return assets, nil
   305  }
   306  
   307  func (o *Client) FetchAllAssetsByOwnerAndContractAddress(ctx context.Context, chainID walletCommon.ChainID, owner common.Address, contractAddresses []common.Address, cursor string, limit int) (*thirdparty.FullCollectibleDataContainer, error) {
   308  	return nil, thirdparty.ErrEndpointNotSupported
   309  }
   310  
   311  func getCollectibleUniqueIDBatches(ids []thirdparty.CollectibleUniqueID) []BatchTokenIDs {
   312  	batches := make([]BatchTokenIDs, 0)
   313  
   314  	for startIdx := 0; startIdx < len(ids); startIdx += nftMetadataBatchLimit {
   315  		endIdx := startIdx + nftMetadataBatchLimit
   316  		if endIdx > len(ids) {
   317  			endIdx = len(ids)
   318  		}
   319  
   320  		pageIDs := ids[startIdx:endIdx]
   321  
   322  		batchIDs := BatchTokenIDs{
   323  			IDs: make([]string, 0, len(pageIDs)),
   324  		}
   325  		for _, id := range pageIDs {
   326  			batchID := fmt.Sprintf("%s:%s:%s", chainIDToChainString(id.ContractID.ChainID), id.ContractID.Address.String(), id.TokenID.String())
   327  			batchIDs.IDs = append(batchIDs.IDs, batchID)
   328  		}
   329  
   330  		batches = append(batches, batchIDs)
   331  	}
   332  
   333  	return batches
   334  }
   335  
   336  func (o *Client) fetchAssetsByBatchTokenIDs(ctx context.Context, chainID walletCommon.ChainID, batchIDs BatchTokenIDs) ([]thirdparty.FullCollectibleData, error) {
   337  	baseURL, err := getItemBaseURL(chainID)
   338  
   339  	if err != nil {
   340  		return nil, err
   341  	}
   342  
   343  	url := fmt.Sprintf("%s/byIds", baseURL)
   344  
   345  	resp, err := o.doPostWithJSON(ctx, url, batchIDs, o.getAPIKey(chainID))
   346  	if err != nil {
   347  		return nil, err
   348  	}
   349  
   350  	defer resp.Body.Close()
   351  
   352  	body, err := ioutil.ReadAll(resp.Body)
   353  	if err != nil {
   354  		return nil, err
   355  	}
   356  
   357  	// if Json is not returned there must be an error
   358  	if !json.Valid(body) {
   359  		return nil, fmt.Errorf("invalid json: %s", string(body))
   360  	}
   361  
   362  	var assets CollectiblesContainer
   363  	err = json.Unmarshal(body, &assets)
   364  	if err != nil {
   365  		return nil, err
   366  	}
   367  
   368  	ret := raribleToCollectiblesData(assets.Collectibles, chainID.IsMainnet())
   369  
   370  	return ret, nil
   371  }
   372  
   373  func (o *Client) FetchAssetsByCollectibleUniqueID(ctx context.Context, uniqueIDs []thirdparty.CollectibleUniqueID) ([]thirdparty.FullCollectibleData, error) {
   374  	ret := make([]thirdparty.FullCollectibleData, 0, len(uniqueIDs))
   375  
   376  	idsPerChainID := thirdparty.GroupCollectibleUIDsByChainID(uniqueIDs)
   377  
   378  	for chainID, ids := range idsPerChainID {
   379  		batches := getCollectibleUniqueIDBatches(ids)
   380  		for _, batch := range batches {
   381  			assets, err := o.fetchAssetsByBatchTokenIDs(ctx, chainID, batch)
   382  			if err != nil {
   383  				return nil, err
   384  			}
   385  
   386  			ret = append(ret, assets...)
   387  		}
   388  	}
   389  
   390  	return ret, nil
   391  }
   392  
   393  func (o *Client) FetchCollectionSocials(ctx context.Context, contractID thirdparty.ContractID) (*thirdparty.CollectionSocials, error) {
   394  	return nil, thirdparty.ErrEndpointNotSupported
   395  }
   396  
   397  func (o *Client) FetchCollectionsDataByContractID(ctx context.Context, contractIDs []thirdparty.ContractID) ([]thirdparty.CollectionData, error) {
   398  	ret := make([]thirdparty.CollectionData, 0, len(contractIDs))
   399  
   400  	for _, contractID := range contractIDs {
   401  		baseURL, err := getCollectionBaseURL(contractID.ChainID)
   402  
   403  		if err != nil {
   404  			return nil, err
   405  		}
   406  
   407  		url := fmt.Sprintf("%s/%s:%s", baseURL, chainIDToChainString(contractID.ChainID), contractID.Address.String())
   408  
   409  		resp, err := o.doQuery(ctx, url, o.getAPIKey(contractID.ChainID))
   410  		if err != nil {
   411  			if ctx.Err() == nil {
   412  				o.connectionStatus.SetIsConnected(false)
   413  			}
   414  			return nil, err
   415  		}
   416  		o.connectionStatus.SetIsConnected(true)
   417  
   418  		defer resp.Body.Close()
   419  
   420  		body, err := ioutil.ReadAll(resp.Body)
   421  		if err != nil {
   422  			return nil, err
   423  		}
   424  
   425  		// if Json is not returned there must be an error
   426  		if !json.Valid(body) {
   427  			return nil, fmt.Errorf("invalid json: %s", string(body))
   428  		}
   429  
   430  		var collection Collection
   431  		err = json.Unmarshal(body, &collection)
   432  		if err != nil {
   433  			return nil, err
   434  		}
   435  
   436  		ret = append(ret, collection.toCommon(contractID))
   437  	}
   438  
   439  	return ret, nil
   440  }
   441  
   442  func (o *Client) searchCollectibles(ctx context.Context, chainID walletCommon.ChainID, collections []common.Address, fullText CollectibleFilterFullText, sort CollectibleFilterContainerSort, cursor string, limit int) (*thirdparty.FullCollectibleDataContainer, error) {
   443  	baseURL, err := getItemBaseURL(chainID)
   444  	if err != nil {
   445  		return nil, err
   446  	}
   447  
   448  	url := fmt.Sprintf("%s/search", baseURL)
   449  
   450  	ret := &thirdparty.FullCollectibleDataContainer{
   451  		Provider:       o.ID(),
   452  		Items:          make([]thirdparty.FullCollectibleData, 0),
   453  		PreviousCursor: cursor,
   454  		NextCursor:     "",
   455  	}
   456  
   457  	if fullText.Text == "" {
   458  		return ret, nil
   459  	}
   460  
   461  	tmpLimit := searchCollectiblesLimit
   462  	if limit > thirdparty.FetchNoLimit && limit < tmpLimit {
   463  		tmpLimit = limit
   464  	}
   465  
   466  	blockchainString := chainIDToChainString(chainID)
   467  
   468  	filterContainer := CollectibleFilterContainer{
   469  		Cursor: cursor,
   470  		Limit:  tmpLimit,
   471  		Filter: CollectibleFilter{
   472  			Blockchains: []string{blockchainString},
   473  			Deleted:     false,
   474  			FullText:    fullText,
   475  		},
   476  		Sort: sort,
   477  	}
   478  
   479  	for _, collection := range collections {
   480  		filterContainer.Filter.Collections = append(filterContainer.Filter.Collections, fmt.Sprintf("%s:%s", blockchainString, collection.String()))
   481  	}
   482  
   483  	for {
   484  		resp, err := o.doPostWithJSON(ctx, url, filterContainer, o.getAPIKey(chainID))
   485  		if err != nil {
   486  			if ctx.Err() == nil {
   487  				o.connectionStatus.SetIsConnected(false)
   488  			}
   489  			return nil, err
   490  		}
   491  		o.connectionStatus.SetIsConnected(true)
   492  
   493  		defer resp.Body.Close()
   494  
   495  		body, err := ioutil.ReadAll(resp.Body)
   496  		if err != nil {
   497  			return nil, err
   498  		}
   499  
   500  		// if Json is not returned there must be an error
   501  		if !json.Valid(body) {
   502  			return nil, fmt.Errorf("invalid json: %s", string(body))
   503  		}
   504  
   505  		var collectibles CollectiblesContainer
   506  		err = json.Unmarshal(body, &collectibles)
   507  		if err != nil {
   508  			return nil, err
   509  		}
   510  
   511  		ret.Items = append(ret.Items, raribleToCollectiblesData(collectibles.Collectibles, chainID.IsMainnet())...)
   512  		ret.NextCursor = collectibles.Continuation
   513  
   514  		if len(ret.NextCursor) == 0 {
   515  			break
   516  		}
   517  
   518  		filterContainer.Cursor = ret.NextCursor
   519  
   520  		if limit != thirdparty.FetchNoLimit && len(ret.Items) >= limit {
   521  			break
   522  		}
   523  	}
   524  
   525  	return ret, nil
   526  }
   527  
   528  func (o *Client) searchCollections(ctx context.Context, chainID walletCommon.ChainID, text string, cursor string, limit int) (*thirdparty.CollectionDataContainer, error) {
   529  	baseURL, err := getCollectionBaseURL(chainID)
   530  	if err != nil {
   531  		return nil, err
   532  	}
   533  
   534  	url := fmt.Sprintf("%s/search", baseURL)
   535  
   536  	ret := &thirdparty.CollectionDataContainer{
   537  		Provider:       o.ID(),
   538  		Items:          make([]thirdparty.CollectionData, 0),
   539  		PreviousCursor: cursor,
   540  		NextCursor:     "",
   541  	}
   542  
   543  	if text == "" {
   544  		return ret, nil
   545  	}
   546  
   547  	tmpLimit := searchCollectionsLimit
   548  	if limit > thirdparty.FetchNoLimit && limit < tmpLimit {
   549  		tmpLimit = limit
   550  	}
   551  
   552  	filterContainer := CollectionFilterContainer{
   553  		Cursor: cursor,
   554  		Limit:  tmpLimit,
   555  		Filter: CollectionFilter{
   556  			Blockchains: []string{chainIDToChainString(chainID)},
   557  			Text:        text,
   558  		},
   559  	}
   560  
   561  	for {
   562  		resp, err := o.doPostWithJSON(ctx, url, filterContainer, o.getAPIKey(chainID))
   563  		if err != nil {
   564  			if ctx.Err() == nil {
   565  				o.connectionStatus.SetIsConnected(false)
   566  			}
   567  			return nil, err
   568  		}
   569  		o.connectionStatus.SetIsConnected(true)
   570  
   571  		defer resp.Body.Close()
   572  
   573  		body, err := ioutil.ReadAll(resp.Body)
   574  		if err != nil {
   575  			return nil, err
   576  		}
   577  
   578  		// if Json is not returned there must be an error
   579  		if !json.Valid(body) {
   580  			return nil, fmt.Errorf("invalid json: %s", string(body))
   581  		}
   582  
   583  		var collections CollectionsContainer
   584  		err = json.Unmarshal(body, &collections)
   585  		if err != nil {
   586  			return nil, err
   587  		}
   588  
   589  		ret.Items = append(ret.Items, raribleToCollectionsData(collections.Collections, chainID.IsMainnet())...)
   590  		ret.NextCursor = collections.Continuation
   591  
   592  		if len(ret.NextCursor) == 0 {
   593  			break
   594  		}
   595  
   596  		filterContainer.Cursor = ret.NextCursor
   597  
   598  		if limit != thirdparty.FetchNoLimit && len(ret.Items) >= limit {
   599  			break
   600  		}
   601  	}
   602  
   603  	return ret, nil
   604  }
   605  
   606  func (o *Client) SearchCollections(ctx context.Context, chainID walletCommon.ChainID, text string, cursor string, limit int) (*thirdparty.CollectionDataContainer, error) {
   607  	return o.searchCollections(ctx, chainID, text, cursor, limit)
   608  }
   609  
   610  func (o *Client) SearchCollectibles(ctx context.Context, chainID walletCommon.ChainID, collections []common.Address, text string, cursor string, limit int) (*thirdparty.FullCollectibleDataContainer, error) {
   611  	fullText := CollectibleFilterFullText{
   612  		Text: text,
   613  		Fields: []string{
   614  			CollectibleFilterFullTextFieldName,
   615  		},
   616  	}
   617  
   618  	sort := CollectibleFilterContainerSortRelevance
   619  
   620  	return o.searchCollectibles(ctx, chainID, collections, fullText, sort, cursor, limit)
   621  }