status-go/services/wallet/thirdparty/opensea/client_v2.go

328 lines
8.8 KiB
Go

package opensea
import (
"context"
"encoding/json"
"fmt"
"net/url"
"strconv"
"strings"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/log"
walletCommon "github.com/status-im/status-go/services/wallet/common"
"github.com/status-im/status-go/services/wallet/connection"
"github.com/status-im/status-go/services/wallet/thirdparty"
)
const assetLimitV2 = 50
func getV2BaseURL(chainID walletCommon.ChainID) (string, error) {
switch uint64(chainID) {
case walletCommon.EthereumMainnet, walletCommon.ArbitrumMainnet, walletCommon.OptimismMainnet:
return "https://api.opensea.io/v2", nil
case walletCommon.EthereumSepolia, walletCommon.ArbitrumSepolia, walletCommon.OptimismSepolia:
return "https://testnets-api.opensea.io/v2", nil
}
return "", thirdparty.ErrChainIDNotSupported
}
func (o *ClientV2) ID() string {
return OpenseaV2ID
}
func (o *ClientV2) IsChainSupported(chainID walletCommon.ChainID) bool {
_, err := getV2BaseURL(chainID)
return err == nil
}
func (o *ClientV2) IsConnected() bool {
return o.connectionStatus.IsConnected()
}
func getV2URL(chainID walletCommon.ChainID, path string) (string, error) {
baseURL, err := getV2BaseURL(chainID)
if err != nil {
return "", err
}
return fmt.Sprintf("%s/%s", baseURL, path), nil
}
type ClientV2 struct {
client *HTTPClient
apiKey string
connectionStatus *connection.Status
urlGetter urlGetter
}
// new opensea v2 client.
func NewClientV2(apiKey string, httpClient *HTTPClient) *ClientV2 {
if apiKey == "" {
log.Warn("OpenseaV2 API key not available")
}
return &ClientV2{
client: httpClient,
apiKey: apiKey,
connectionStatus: connection.NewStatus(),
urlGetter: getV2URL,
}
}
func (o *ClientV2) FetchAllAssetsByOwnerAndContractAddress(ctx context.Context, chainID walletCommon.ChainID, owner common.Address, contractAddresses []common.Address, cursor string, limit int) (*thirdparty.FullCollectibleDataContainer, error) {
// No dedicated endpoint to filter owned assets by contract address.
// Will probably be available at some point, for now do the filtering ourselves.
assets := new(thirdparty.FullCollectibleDataContainer)
// Build map for more efficient contract address check
contractHashMap := make(map[string]bool)
for _, contractAddress := range contractAddresses {
contractID := thirdparty.ContractID{
ChainID: chainID,
Address: contractAddress,
}
contractHashMap[contractID.HashKey()] = true
}
assets.PreviousCursor = cursor
assets.NextCursor = cursor
assets.Provider = o.ID()
for {
assetsPage, err := o.FetchAllAssetsByOwner(ctx, chainID, owner, assets.NextCursor, assetLimitV2)
if err != nil {
return nil, err
}
for _, asset := range assetsPage.Items {
if contractHashMap[asset.CollectibleData.ID.ContractID.HashKey()] {
assets.Items = append(assets.Items, asset)
}
}
assets.NextCursor = assetsPage.NextCursor
if assets.NextCursor == "" {
break
}
if limit > thirdparty.FetchNoLimit && len(assets.Items) >= limit {
break
}
}
return assets, nil
}
func (o *ClientV2) FetchAllAssetsByOwner(ctx context.Context, chainID walletCommon.ChainID, owner common.Address, cursor string, limit int) (*thirdparty.FullCollectibleDataContainer, error) {
pathParams := []string{
"chain", chainIDToChainString(chainID),
"account", owner.String(),
"nfts",
}
queryParams := url.Values{}
return o.fetchAssets(ctx, chainID, pathParams, queryParams, limit, cursor)
}
func (o *ClientV2) FetchAssetsByCollectibleUniqueID(ctx context.Context, uniqueIDs []thirdparty.CollectibleUniqueID) ([]thirdparty.FullCollectibleData, error) {
return o.fetchDetailedAssets(ctx, uniqueIDs)
}
func (o *ClientV2) FetchCollectionSocials(ctx context.Context, contractID thirdparty.ContractID) (*thirdparty.CollectionSocials, error) {
// we dont want to use opensea as any small number of requests can also lead to throttling
return nil, thirdparty.ErrEndpointNotSupported
}
func (o *ClientV2) fetchAssets(ctx context.Context, chainID walletCommon.ChainID, pathParams []string, queryParams url.Values, limit int, cursor string) (*thirdparty.FullCollectibleDataContainer, error) {
assets := new(thirdparty.FullCollectibleDataContainer)
tmpLimit := assetLimitV2
if limit > thirdparty.FetchNoLimit && limit < tmpLimit {
tmpLimit = limit
}
queryParams["limit"] = []string{strconv.Itoa(tmpLimit)}
assets.PreviousCursor = cursor
if cursor != "" {
queryParams["next"] = []string{cursor}
}
assets.Provider = o.ID()
for {
path := fmt.Sprintf("%s?%s", strings.Join(pathParams, "/"), queryParams.Encode())
url, err := o.urlGetter(chainID, path)
if err != nil {
return nil, err
}
body, err := o.client.doGetRequest(ctx, url, o.apiKey)
if err != nil {
if ctx.Err() == nil {
o.connectionStatus.SetIsConnected(false)
}
return nil, err
}
o.connectionStatus.SetIsConnected(true)
// If body is empty, it means the account has no collectibles for this chain.
// (Workaround implemented in http_client.go)
if body == nil {
assets.NextCursor = ""
break
}
// if Json is not returned there must be an error
if !json.Valid(body) {
return nil, fmt.Errorf("invalid json: %s", string(body))
}
container := NFTContainer{}
err = json.Unmarshal(body, &container)
if err != nil {
return nil, err
}
for _, asset := range container.NFTs {
assets.Items = append(assets.Items, asset.toCommon(chainID))
}
assets.NextCursor = container.NextCursor
if assets.NextCursor == "" {
break
}
queryParams["next"] = []string{assets.NextCursor}
if limit > thirdparty.FetchNoLimit && len(assets.Items) >= limit {
break
}
}
return assets, nil
}
func (o *ClientV2) fetchDetailedAssets(ctx context.Context, uniqueIDs []thirdparty.CollectibleUniqueID) ([]thirdparty.FullCollectibleData, error) {
assets := make([]thirdparty.FullCollectibleData, 0, len(uniqueIDs))
for _, id := range uniqueIDs {
path := fmt.Sprintf("chain/%s/contract/%s/nfts/%s", chainIDToChainString(id.ContractID.ChainID), id.ContractID.Address.String(), id.TokenID.String())
url, err := o.urlGetter(id.ContractID.ChainID, path)
if err != nil {
return nil, err
}
body, err := o.client.doGetRequest(ctx, url, o.apiKey)
if err != nil {
if ctx.Err() == nil {
o.connectionStatus.SetIsConnected(false)
}
return nil, err
}
o.connectionStatus.SetIsConnected(true)
// if Json is not returned there must be an error
if !json.Valid(body) {
return nil, fmt.Errorf("invalid json: %s", string(body))
}
nftContainer := DetailedNFTContainer{}
err = json.Unmarshal(body, &nftContainer)
if err != nil {
return nil, err
}
assets = append(assets, nftContainer.NFT.toCommon(id.ContractID.ChainID))
}
return assets, nil
}
func (o *ClientV2) fetchContractDataByContractID(ctx context.Context, id thirdparty.ContractID) (*ContractData, error) {
path := fmt.Sprintf("chain/%s/contract/%s", chainIDToChainString(id.ChainID), id.Address.String())
url, err := o.urlGetter(id.ChainID, path)
if err != nil {
return nil, err
}
body, err := o.client.doGetRequest(ctx, url, o.apiKey)
if err != nil {
if ctx.Err() == nil {
o.connectionStatus.SetIsConnected(false)
}
return nil, err
}
o.connectionStatus.SetIsConnected(true)
// if Json is not returned there must be an error
if !json.Valid(body) {
return nil, fmt.Errorf("invalid json: %s", string(body))
}
contract := ContractData{}
err = json.Unmarshal(body, &contract)
if err != nil {
return nil, err
}
return &contract, nil
}
func (o *ClientV2) fetchCollectionDataBySlug(ctx context.Context, chainID walletCommon.ChainID, slug string) (*CollectionData, error) {
path := fmt.Sprintf("collections/%s", slug)
url, err := o.urlGetter(chainID, path)
if err != nil {
return nil, err
}
body, err := o.client.doGetRequest(ctx, url, o.apiKey)
if err != nil {
if ctx.Err() == nil {
o.connectionStatus.SetIsConnected(false)
}
return nil, err
}
o.connectionStatus.SetIsConnected(true)
// if Json is not returned there must be an error
if !json.Valid(body) {
return nil, fmt.Errorf("invalid json: %s", string(body))
}
collection := CollectionData{}
err = json.Unmarshal(body, &collection)
if err != nil {
return nil, err
}
return &collection, nil
}
func (o *ClientV2) FetchCollectionsDataByContractID(ctx context.Context, contractIDs []thirdparty.ContractID) ([]thirdparty.CollectionData, error) {
ret := make([]thirdparty.CollectionData, 0, len(contractIDs))
for _, id := range contractIDs {
contractData, err := o.fetchContractDataByContractID(ctx, id)
if err != nil {
return nil, err
}
if contractData == nil || contractData.Collection == "" {
continue
}
collectionData, err := o.fetchCollectionDataBySlug(ctx, id.ChainID, contractData.Collection)
if err != nil {
return nil, err
}
ret = append(ret, collectionData.toCommon(id, contractData.ContractStandard))
}
return ret, nil
}