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

     1  package alchemy
     2  
     3  import (
     4  	"context"
     5  	"encoding/json"
     6  	"fmt"
     7  	"io/ioutil"
     8  	"net/http"
     9  	"net/url"
    10  	"strings"
    11  	"time"
    12  
    13  	"github.com/cenkalti/backoff/v4"
    14  
    15  	"github.com/ethereum/go-ethereum/common"
    16  	"github.com/ethereum/go-ethereum/log"
    17  
    18  	walletCommon "github.com/status-im/status-go/services/wallet/common"
    19  	"github.com/status-im/status-go/services/wallet/connection"
    20  	"github.com/status-im/status-go/services/wallet/thirdparty"
    21  )
    22  
    23  const nftMetadataBatchLimit = 100
    24  const contractMetadataBatchLimit = 100
    25  
    26  func getBaseURL(chainID walletCommon.ChainID) (string, error) {
    27  	switch uint64(chainID) {
    28  	case walletCommon.EthereumMainnet:
    29  		return "https://eth-mainnet.g.alchemy.com", nil
    30  	case walletCommon.EthereumGoerli:
    31  		return "https://eth-goerli.g.alchemy.com", nil
    32  	case walletCommon.EthereumSepolia:
    33  		return "https://eth-sepolia.g.alchemy.com", nil
    34  	case walletCommon.OptimismMainnet:
    35  		return "https://opt-mainnet.g.alchemy.com", nil
    36  	case walletCommon.OptimismSepolia:
    37  		return "https://opt-sepolia.g.alchemy.com", nil
    38  	case walletCommon.ArbitrumMainnet:
    39  		return "https://arb-mainnet.g.alchemy.com", nil
    40  	case walletCommon.ArbitrumGoerli:
    41  		return "https://arb-goerli.g.alchemy.com", nil
    42  	case walletCommon.ArbitrumSepolia:
    43  		return "https://arb-sepolia.g.alchemy.com", nil
    44  	}
    45  
    46  	return "", thirdparty.ErrChainIDNotSupported
    47  }
    48  
    49  func (o *Client) ID() string {
    50  	return AlchemyID
    51  }
    52  
    53  func (o *Client) IsChainSupported(chainID walletCommon.ChainID) bool {
    54  	_, err := getBaseURL(chainID)
    55  	return err == nil
    56  }
    57  
    58  func (o *Client) IsConnected() bool {
    59  	return o.connectionStatus.IsConnected()
    60  }
    61  
    62  func getAPIKeySubpath(apiKey string) string {
    63  	if apiKey == "" {
    64  		return "demo"
    65  	}
    66  	return apiKey
    67  }
    68  
    69  func getNFTBaseURL(chainID walletCommon.ChainID, apiKey string) (string, error) {
    70  	baseURL, err := getBaseURL(chainID)
    71  
    72  	if err != nil {
    73  		return "", err
    74  	}
    75  
    76  	return fmt.Sprintf("%s/nft/v3/%s", baseURL, getAPIKeySubpath(apiKey)), nil
    77  }
    78  
    79  type Client struct {
    80  	thirdparty.CollectibleContractOwnershipProvider
    81  	client           *http.Client
    82  	apiKeys          map[uint64]string
    83  	connectionStatus *connection.Status
    84  }
    85  
    86  func NewClient(apiKeys map[uint64]string) *Client {
    87  	for _, chainID := range walletCommon.AllChainIDs() {
    88  		if apiKeys[uint64(chainID)] == "" {
    89  			log.Warn("Alchemy API key not available for", "chainID", chainID)
    90  		}
    91  	}
    92  
    93  	return &Client{
    94  		client:           &http.Client{Timeout: time.Minute},
    95  		apiKeys:          apiKeys,
    96  		connectionStatus: connection.NewStatus(),
    97  	}
    98  }
    99  
   100  func (o *Client) doQuery(ctx context.Context, url string) (*http.Response, error) {
   101  	req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
   102  	if err != nil {
   103  		return nil, err
   104  	}
   105  
   106  	return o.doWithRetries(req)
   107  }
   108  
   109  func (o *Client) doPostWithJSON(ctx context.Context, url string, payload any) (*http.Response, error) {
   110  	payloadJSON, err := json.Marshal(payload)
   111  	if err != nil {
   112  		return nil, err
   113  	}
   114  
   115  	payloadString := string(payloadJSON)
   116  	payloadReader := strings.NewReader(payloadString)
   117  
   118  	req, err := http.NewRequestWithContext(ctx, "POST", url, payloadReader)
   119  	if err != nil {
   120  		return nil, err
   121  	}
   122  
   123  	req.Header.Add("accept", "application/json")
   124  	req.Header.Add("content-type", "application/json")
   125  
   126  	return o.doWithRetries(req)
   127  }
   128  
   129  func (o *Client) doWithRetries(req *http.Request) (*http.Response, error) {
   130  	b := backoff.NewExponentialBackOff()
   131  	b.InitialInterval = time.Millisecond * 1000
   132  	b.RandomizationFactor = 0.1
   133  	b.Multiplier = 1.5
   134  	b.MaxInterval = time.Second * 32
   135  	b.MaxElapsedTime = time.Second * 70
   136  
   137  	b.Reset()
   138  
   139  	op := func() (*http.Response, error) {
   140  		resp, err := o.client.Do(req)
   141  		if err != nil {
   142  			return nil, backoff.Permanent(err)
   143  		}
   144  
   145  		if resp.StatusCode == http.StatusOK {
   146  			return resp, nil
   147  		}
   148  
   149  		err = fmt.Errorf("unsuccessful request: %d %s", resp.StatusCode, http.StatusText(resp.StatusCode))
   150  		if resp.StatusCode == http.StatusTooManyRequests {
   151  			log.Error("doWithRetries failed with http.StatusTooManyRequests", "provider", o.ID(), "elapsed time", b.GetElapsedTime(), "next backoff", b.NextBackOff())
   152  			return nil, err
   153  		}
   154  		return nil, backoff.Permanent(err)
   155  	}
   156  
   157  	return backoff.RetryWithData(op, b)
   158  }
   159  
   160  func (o *Client) FetchCollectibleOwnersByContractAddress(ctx context.Context, chainID walletCommon.ChainID, contractAddress common.Address) (*thirdparty.CollectibleContractOwnership, error) {
   161  	ownership := thirdparty.CollectibleContractOwnership{
   162  		ContractAddress: contractAddress,
   163  		Owners:          make([]thirdparty.CollectibleOwner, 0),
   164  	}
   165  
   166  	queryParams := url.Values{
   167  		"contractAddress":   {contractAddress.String()},
   168  		"withTokenBalances": {"true"},
   169  	}
   170  
   171  	baseURL, err := getNFTBaseURL(chainID, o.apiKeys[uint64(chainID)])
   172  
   173  	if err != nil {
   174  		return nil, err
   175  	}
   176  
   177  	for {
   178  		url := fmt.Sprintf("%s/getOwnersForContract?%s", baseURL, queryParams.Encode())
   179  
   180  		resp, err := o.doQuery(ctx, url)
   181  		if err != nil {
   182  			if ctx.Err() == nil {
   183  				o.connectionStatus.SetIsConnected(false)
   184  			}
   185  			return nil, err
   186  		}
   187  		o.connectionStatus.SetIsConnected(true)
   188  
   189  		defer resp.Body.Close()
   190  
   191  		body, err := ioutil.ReadAll(resp.Body)
   192  		if err != nil {
   193  			return nil, err
   194  		}
   195  
   196  		var alchemyOwnership CollectibleContractOwnership
   197  		err = json.Unmarshal(body, &alchemyOwnership)
   198  		if err != nil {
   199  			return nil, err
   200  		}
   201  
   202  		ownership.Owners = append(ownership.Owners, alchemyCollectibleOwnersToCommon(alchemyOwnership.Owners)...)
   203  
   204  		if alchemyOwnership.PageKey == "" {
   205  			break
   206  		}
   207  
   208  		queryParams["pageKey"] = []string{alchemyOwnership.PageKey}
   209  	}
   210  
   211  	return &ownership, nil
   212  }
   213  
   214  func (o *Client) FetchAllAssetsByOwner(ctx context.Context, chainID walletCommon.ChainID, owner common.Address, cursor string, limit int) (*thirdparty.FullCollectibleDataContainer, error) {
   215  	queryParams := url.Values{}
   216  
   217  	return o.fetchOwnedAssets(ctx, chainID, owner, queryParams, cursor, limit)
   218  }
   219  
   220  func (o *Client) FetchAllAssetsByOwnerAndContractAddress(ctx context.Context, chainID walletCommon.ChainID, owner common.Address, contractAddresses []common.Address, cursor string, limit int) (*thirdparty.FullCollectibleDataContainer, error) {
   221  	queryParams := url.Values{}
   222  
   223  	for _, contractAddress := range contractAddresses {
   224  		queryParams.Add("contractAddresses", contractAddress.String())
   225  	}
   226  
   227  	return o.fetchOwnedAssets(ctx, chainID, owner, queryParams, cursor, limit)
   228  }
   229  
   230  func (o *Client) fetchOwnedAssets(ctx context.Context, chainID walletCommon.ChainID, owner common.Address, queryParams url.Values, cursor string, limit int) (*thirdparty.FullCollectibleDataContainer, error) {
   231  	assets := new(thirdparty.FullCollectibleDataContainer)
   232  
   233  	queryParams["owner"] = []string{owner.String()}
   234  	queryParams["withMetadata"] = []string{"true"}
   235  
   236  	if len(cursor) > 0 {
   237  		queryParams["pageKey"] = []string{cursor}
   238  		assets.PreviousCursor = cursor
   239  	}
   240  	assets.Provider = o.ID()
   241  
   242  	baseURL, err := getNFTBaseURL(chainID, o.apiKeys[uint64(chainID)])
   243  
   244  	if err != nil {
   245  		return nil, err
   246  	}
   247  
   248  	for {
   249  		url := fmt.Sprintf("%s/getNFTsForOwner?%s", baseURL, queryParams.Encode())
   250  
   251  		resp, err := o.doQuery(ctx, url)
   252  		if err != nil {
   253  			if ctx.Err() == nil {
   254  				o.connectionStatus.SetIsConnected(false)
   255  			}
   256  			return nil, err
   257  		}
   258  		o.connectionStatus.SetIsConnected(true)
   259  
   260  		defer resp.Body.Close()
   261  
   262  		body, err := ioutil.ReadAll(resp.Body)
   263  		if err != nil {
   264  			return nil, err
   265  		}
   266  
   267  		// if Json is not returned there must be an error
   268  		if !json.Valid(body) {
   269  			return nil, fmt.Errorf("invalid json: %s", string(body))
   270  		}
   271  
   272  		container := OwnedNFTList{}
   273  		err = json.Unmarshal(body, &container)
   274  		if err != nil {
   275  			return nil, err
   276  		}
   277  
   278  		assets.Items = append(assets.Items, alchemyToCollectiblesData(chainID, container.OwnedNFTs)...)
   279  		assets.NextCursor = container.PageKey
   280  
   281  		if len(assets.NextCursor) == 0 {
   282  			break
   283  		}
   284  
   285  		queryParams["cursor"] = []string{assets.NextCursor}
   286  
   287  		if limit != thirdparty.FetchNoLimit && len(assets.Items) >= limit {
   288  			break
   289  		}
   290  	}
   291  
   292  	return assets, nil
   293  }
   294  
   295  func getCollectibleUniqueIDBatches(ids []thirdparty.CollectibleUniqueID) []BatchTokenIDs {
   296  	batches := make([]BatchTokenIDs, 0)
   297  
   298  	for startIdx := 0; startIdx < len(ids); startIdx += nftMetadataBatchLimit {
   299  		endIdx := startIdx + nftMetadataBatchLimit
   300  		if endIdx > len(ids) {
   301  			endIdx = len(ids)
   302  		}
   303  
   304  		pageIDs := ids[startIdx:endIdx]
   305  
   306  		batchIDs := BatchTokenIDs{
   307  			IDs: make([]TokenID, 0, len(pageIDs)),
   308  		}
   309  		for _, id := range pageIDs {
   310  			batchID := TokenID{
   311  				ContractAddress: id.ContractID.Address,
   312  				TokenID:         id.TokenID,
   313  			}
   314  			batchIDs.IDs = append(batchIDs.IDs, batchID)
   315  		}
   316  
   317  		batches = append(batches, batchIDs)
   318  	}
   319  
   320  	return batches
   321  }
   322  
   323  func (o *Client) fetchAssetsByBatchTokenIDs(ctx context.Context, chainID walletCommon.ChainID, batchIDs BatchTokenIDs) ([]thirdparty.FullCollectibleData, error) {
   324  	baseURL, err := getNFTBaseURL(chainID, o.apiKeys[uint64(chainID)])
   325  	if err != nil {
   326  		return nil, err
   327  	}
   328  
   329  	url := fmt.Sprintf("%s/getNFTMetadataBatch", baseURL)
   330  
   331  	resp, err := o.doPostWithJSON(ctx, url, batchIDs)
   332  	if err != nil {
   333  		return nil, err
   334  	}
   335  
   336  	defer resp.Body.Close()
   337  
   338  	body, err := ioutil.ReadAll(resp.Body)
   339  	if err != nil {
   340  		return nil, err
   341  	}
   342  
   343  	// if Json is not returned there must be an error
   344  	if !json.Valid(body) {
   345  		return nil, fmt.Errorf("invalid json: %s", string(body))
   346  	}
   347  
   348  	assets := NFTList{}
   349  	err = json.Unmarshal(body, &assets)
   350  	if err != nil {
   351  		return nil, err
   352  	}
   353  
   354  	ret := alchemyToCollectiblesData(chainID, assets.NFTs)
   355  
   356  	return ret, nil
   357  }
   358  
   359  func (o *Client) FetchAssetsByCollectibleUniqueID(ctx context.Context, uniqueIDs []thirdparty.CollectibleUniqueID) ([]thirdparty.FullCollectibleData, error) {
   360  	ret := make([]thirdparty.FullCollectibleData, 0, len(uniqueIDs))
   361  
   362  	idsPerChainID := thirdparty.GroupCollectibleUIDsByChainID(uniqueIDs)
   363  
   364  	for chainID, ids := range idsPerChainID {
   365  		batches := getCollectibleUniqueIDBatches(ids)
   366  		for _, batch := range batches {
   367  			assets, err := o.fetchAssetsByBatchTokenIDs(ctx, chainID, batch)
   368  			if err != nil {
   369  				return nil, err
   370  			}
   371  
   372  			ret = append(ret, assets...)
   373  		}
   374  	}
   375  
   376  	return ret, nil
   377  }
   378  
   379  func (o *Client) FetchCollectionSocials(ctx context.Context, contractID thirdparty.ContractID) (*thirdparty.CollectionSocials, error) {
   380  	resp, err := o.FetchCollectionsDataByContractID(ctx, []thirdparty.ContractID{contractID})
   381  	if err != nil {
   382  		return nil, err
   383  	}
   384  	if len(resp) > 0 {
   385  		return resp[0].Socials, nil
   386  	}
   387  	return nil, nil
   388  }
   389  
   390  func getContractAddressBatches(ids []thirdparty.ContractID) []BatchContractAddresses {
   391  	batches := make([]BatchContractAddresses, 0)
   392  
   393  	for startIdx := 0; startIdx < len(ids); startIdx += contractMetadataBatchLimit {
   394  		endIdx := startIdx + contractMetadataBatchLimit
   395  		if endIdx > len(ids) {
   396  			endIdx = len(ids)
   397  		}
   398  
   399  		pageIDs := ids[startIdx:endIdx]
   400  
   401  		batchIDs := BatchContractAddresses{
   402  			Addresses: make([]common.Address, 0, len(pageIDs)),
   403  		}
   404  		for _, id := range pageIDs {
   405  			batchIDs.Addresses = append(batchIDs.Addresses, id.Address)
   406  		}
   407  
   408  		batches = append(batches, batchIDs)
   409  	}
   410  
   411  	return batches
   412  }
   413  
   414  func (o *Client) fetchCollectionsDataByBatchContractAddresses(ctx context.Context, chainID walletCommon.ChainID, batchAddresses BatchContractAddresses) ([]thirdparty.CollectionData, error) {
   415  	baseURL, err := getNFTBaseURL(chainID, o.apiKeys[uint64(chainID)])
   416  	if err != nil {
   417  		return nil, err
   418  	}
   419  
   420  	url := fmt.Sprintf("%s/getContractMetadataBatch", baseURL)
   421  
   422  	resp, err := o.doPostWithJSON(ctx, url, batchAddresses)
   423  	if err != nil {
   424  		return nil, err
   425  	}
   426  
   427  	defer resp.Body.Close()
   428  
   429  	body, err := ioutil.ReadAll(resp.Body)
   430  	if err != nil {
   431  		return nil, err
   432  	}
   433  
   434  	// if Json is not returned there must be an error
   435  	if !json.Valid(body) {
   436  		return nil, fmt.Errorf("invalid json: %s", string(body))
   437  	}
   438  
   439  	collections := ContractList{}
   440  	err = json.Unmarshal(body, &collections)
   441  	if err != nil {
   442  		return nil, err
   443  	}
   444  
   445  	ret := alchemyToCollectionsData(chainID, collections.Contracts)
   446  
   447  	return ret, nil
   448  }
   449  
   450  func (o *Client) FetchCollectionsDataByContractID(ctx context.Context, contractIDs []thirdparty.ContractID) ([]thirdparty.CollectionData, error) {
   451  	ret := make([]thirdparty.CollectionData, 0, len(contractIDs))
   452  
   453  	idsPerChainID := thirdparty.GroupContractIDsByChainID(contractIDs)
   454  
   455  	for chainID, ids := range idsPerChainID {
   456  		batches := getContractAddressBatches(ids)
   457  		for _, batch := range batches {
   458  			contractsData, err := o.fetchCollectionsDataByBatchContractAddresses(ctx, chainID, batch)
   459  			if err != nil {
   460  				return nil, err
   461  			}
   462  
   463  			ret = append(ret, contractsData...)
   464  		}
   465  	}
   466  
   467  	return ret, nil
   468  }